@lawrencehui/citio 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/.dockerignore +8 -0
  2. package/Dockerfile +47 -0
  3. package/LICENSE +21 -0
  4. package/README.md +305 -0
  5. package/citio.example.yaml +43 -0
  6. package/dist/adapters/slack.d.ts +22 -0
  7. package/dist/adapters/slack.d.ts.map +1 -0
  8. package/dist/adapters/slack.js +624 -0
  9. package/dist/adapters/slack.js.map +1 -0
  10. package/dist/cli/init.d.ts +3 -0
  11. package/dist/cli/init.d.ts.map +1 -0
  12. package/dist/cli/init.js +1464 -0
  13. package/dist/cli/init.js.map +1 -0
  14. package/dist/cli/ops.d.ts +13 -0
  15. package/dist/cli/ops.d.ts.map +1 -0
  16. package/dist/cli/ops.js +167 -0
  17. package/dist/cli/ops.js.map +1 -0
  18. package/dist/config/schema.d.ts +81 -0
  19. package/dist/config/schema.d.ts.map +1 -0
  20. package/dist/config/schema.js +65 -0
  21. package/dist/config/schema.js.map +1 -0
  22. package/dist/core/agent-runner.d.ts +38 -0
  23. package/dist/core/agent-runner.d.ts.map +1 -0
  24. package/dist/core/agent-runner.js +531 -0
  25. package/dist/core/agent-runner.js.map +1 -0
  26. package/dist/core/mcp-entry.d.ts +7 -0
  27. package/dist/core/mcp-entry.d.ts.map +1 -0
  28. package/dist/core/mcp-entry.js +390 -0
  29. package/dist/core/mcp-entry.js.map +1 -0
  30. package/dist/core/provider-config.d.ts +2 -0
  31. package/dist/core/provider-config.d.ts.map +1 -0
  32. package/dist/core/provider-config.js +53 -0
  33. package/dist/core/provider-config.js.map +1 -0
  34. package/dist/core/session-manager.d.ts +15 -0
  35. package/dist/core/session-manager.d.ts.map +1 -0
  36. package/dist/core/session-manager.js +78 -0
  37. package/dist/core/session-manager.js.map +1 -0
  38. package/dist/core/skills.d.ts +24 -0
  39. package/dist/core/skills.d.ts.map +1 -0
  40. package/dist/core/skills.js +111 -0
  41. package/dist/core/skills.js.map +1 -0
  42. package/dist/core/workspace.d.ts +13 -0
  43. package/dist/core/workspace.d.ts.map +1 -0
  44. package/dist/core/workspace.js +238 -0
  45. package/dist/core/workspace.js.map +1 -0
  46. package/dist/index.d.ts +2 -0
  47. package/dist/index.d.ts.map +1 -0
  48. package/dist/index.js +152 -0
  49. package/dist/index.js.map +1 -0
  50. package/dist/utils/app-state.d.ts +5 -0
  51. package/dist/utils/app-state.d.ts.map +1 -0
  52. package/dist/utils/app-state.js +35 -0
  53. package/dist/utils/app-state.js.map +1 -0
  54. package/dist/utils/claude.d.ts +4 -0
  55. package/dist/utils/claude.d.ts.map +1 -0
  56. package/dist/utils/claude.js +65 -0
  57. package/dist/utils/claude.js.map +1 -0
  58. package/dist/utils/codex.d.ts +5 -0
  59. package/dist/utils/codex.d.ts.map +1 -0
  60. package/dist/utils/codex.js +17 -0
  61. package/dist/utils/codex.js.map +1 -0
  62. package/dist/utils/env.d.ts +2 -0
  63. package/dist/utils/env.d.ts.map +1 -0
  64. package/dist/utils/env.js +16 -0
  65. package/dist/utils/env.js.map +1 -0
  66. package/dist/utils/installer-state.d.ts +33 -0
  67. package/dist/utils/installer-state.d.ts.map +1 -0
  68. package/dist/utils/installer-state.js +152 -0
  69. package/dist/utils/installer-state.js.map +1 -0
  70. package/dist/utils/runtime-env.d.ts +3 -0
  71. package/dist/utils/runtime-env.d.ts.map +1 -0
  72. package/dist/utils/runtime-env.js +24 -0
  73. package/dist/utils/runtime-env.js.map +1 -0
  74. package/dist/utils/secret-store.d.ts +18 -0
  75. package/dist/utils/secret-store.d.ts.map +1 -0
  76. package/dist/utils/secret-store.js +78 -0
  77. package/dist/utils/secret-store.js.map +1 -0
  78. package/dist/utils/slack-onboarding.d.ts +22 -0
  79. package/dist/utils/slack-onboarding.d.ts.map +1 -0
  80. package/dist/utils/slack-onboarding.js +205 -0
  81. package/dist/utils/slack-onboarding.js.map +1 -0
  82. package/package.json +76 -0
@@ -0,0 +1,1464 @@
1
+ #!/usr/bin/env node
2
+ import * as p from "@clack/prompts";
3
+ import pc from "picocolors";
4
+ import { execSync, execFileSync } from "child_process";
5
+ import { writeFileSync, readFileSync, existsSync, chmodSync, unlinkSync } from "fs";
6
+ import path from "path";
7
+ import os from "os";
8
+ import { stringify } from "yaml";
9
+ import { extractClaudeOauthTokenFromTranscript, normalizeClaudeOauthToken, validateClaudeOauthToken } from "../utils/claude.js";
10
+ import { loadSavedInstallerState, saveInstallerState } from "../utils/installer-state.js";
11
+ import { buildCitioSlackManifest, createCitioSlackApp, openBrowser, testSlackBotToken, validateSlackAppToken, validateSlackBotToken, validateSlackConfigToken, } from "../utils/slack-onboarding.js";
12
+ import { SKILL_REGISTRY, installSkillsTo } from "../core/skills.js";
13
+ import { destroyCommand, statusCommand } from "./ops.js";
14
+ // --- styling helpers (color = information, not decoration) --------------------
15
+ /** inline command the user should run: cyan */
16
+ const cmd = (s) => pc.cyan(s);
17
+ /** clickable/visitable URL: underlined cyan */
18
+ const url = (s) => pc.underline(pc.cyan(s));
19
+ /** step/section header inside a note: bold */
20
+ const step = (s) => pc.bold(s);
21
+ /** literal value/token prefix to recognise: yellow */
22
+ const val = (s) => pc.yellow(s);
23
+ /** caution line: yellow */
24
+ const warn = (s) => pc.yellow(s);
25
+ /** positive/checklist mark: green */
26
+ const ok = (s) => pc.green(s);
27
+ function runDeployCommand(command, errorMessage, options = {}) {
28
+ try {
29
+ return execSync(command, {
30
+ stdio: "pipe",
31
+ encoding: options.encoding || "utf-8",
32
+ cwd: options.cwd,
33
+ timeout: options.timeout,
34
+ });
35
+ }
36
+ catch (err) {
37
+ const detail = err instanceof Error ? err.message : String(err);
38
+ p.log.error(`${errorMessage}\n${detail}`);
39
+ process.exit(1);
40
+ }
41
+ }
42
+ function sleep(seconds) {
43
+ execSync(`sleep ${seconds}`, { stdio: "pipe" });
44
+ }
45
+ function resolveEfsFileSystemId(region, profileFlag, createdEfsId) {
46
+ if (createdEfsId) {
47
+ return createdEfsId;
48
+ }
49
+ const discovered = runDeployCommand(`aws efs describe-file-systems --creation-token citio-memory --region ${region} ${profileFlag} --query 'FileSystems[0].FileSystemId' --output text`, "Failed to resolve the Citio EFS filesystem. Re-run the installer with EFS enabled or create the filesystem first.").trim();
50
+ if (!discovered || discovered === "None" || discovered === "null") {
51
+ p.log.error("No Citio EFS filesystem was found for creation token `citio-memory`.");
52
+ process.exit(1);
53
+ }
54
+ return discovered;
55
+ }
56
+ const PREREQ_GUIDES = {
57
+ docker: "docker — runs the container build\n" +
58
+ " macOS/Windows: install Docker Desktop → https://www.docker.com/products/docker-desktop/\n" +
59
+ " Linux: curl -fsSL https://get.docker.com | sh\n" +
60
+ " (then make sure Docker is RUNNING before re-running the installer)",
61
+ "aws-cli": "aws — deploys to your AWS account\n" +
62
+ " macOS: brew install awscli\n" +
63
+ " Ubuntu/Debian: sudo apt install awscli\n" +
64
+ " Windows: https://awscli.amazonaws.com/AWSCLIV2.msi\n" +
65
+ " Full account + permissions guide: docs/AWS_SETUP.md",
66
+ git: "git — clones your repos\n" +
67
+ " macOS: xcode-select --install (or: brew install git)\n" +
68
+ " Ubuntu/Debian: sudo apt install git\n" +
69
+ " Windows: https://git-scm.com/download/win",
70
+ };
71
+ function checkPrerequisites() {
72
+ const missing = [];
73
+ try {
74
+ execSync("docker --version", { stdio: "pipe" });
75
+ }
76
+ catch {
77
+ missing.push("docker");
78
+ }
79
+ try {
80
+ execSync("aws --version", { stdio: "pipe" });
81
+ }
82
+ catch {
83
+ missing.push("aws-cli");
84
+ }
85
+ try {
86
+ execSync("git --version", { stdio: "pipe" });
87
+ }
88
+ catch {
89
+ missing.push("git");
90
+ }
91
+ if (missing.length > 0) {
92
+ p.note(missing.map((tool) => ` ${PREREQ_GUIDES[tool] || tool}`).join("\n\n"), `Missing ${missing.length === 1 ? "prerequisite" : "prerequisites"}: ${missing.join(", ")}`);
93
+ p.log.error("Install the tools above, then re-run the installer — your answers so far are saved.");
94
+ process.exit(1);
95
+ }
96
+ }
97
+ async function ensureAwsCredentials(awsProfile) {
98
+ const profileFlag = awsProfile && awsProfile !== "default" ? `--profile ${awsProfile}` : "";
99
+ for (;;) {
100
+ try {
101
+ const identityJson = execSync(`aws sts get-caller-identity --output json ${profileFlag}`, {
102
+ encoding: "utf-8",
103
+ stdio: "pipe",
104
+ timeout: 20000,
105
+ });
106
+ const identity = JSON.parse(identityJson);
107
+ p.log.success(`AWS credentials verified — deploying to account ${identity.Account}` +
108
+ (identity.Arn ? ` as ${identity.Arn.split("/").pop()}` : "") + ".");
109
+ return;
110
+ }
111
+ catch (err) {
112
+ const detail = err instanceof Error ? err.message : String(err);
113
+ const looksExpired = /expired|sso/i.test(detail);
114
+ p.note((looksExpired
115
+ ? `Your session looks expired. If you use SSO:\n` +
116
+ ` aws sso login${awsProfile && awsProfile !== "default" ? ` --profile ${awsProfile}` : ""}\n\n`
117
+ : "") +
118
+ "The AWS CLI can't authenticate with this profile. To set it up:\n" +
119
+ "\n" +
120
+ " 1. Get an access key: AWS Console → IAM → Users → your user →\n" +
121
+ " Security credentials → “Create access key” (choose CLI)\n" +
122
+ " 2. Run: aws configure" + (awsProfile && awsProfile !== "default" ? ` --profile ${awsProfile}` : "") + "\n" +
123
+ " and paste the key, secret, and a region (e.g. us-east-1)\n" +
124
+ " 3. Verify: aws sts get-caller-identity\n" +
125
+ "\n" +
126
+ "Permissions needed + least-privilege policy: docs/AWS_SETUP.md\n" +
127
+ "(simplest for a personal account: AdministratorAccess)", `AWS credentials check failed${awsProfile ? ` (profile: ${awsProfile})` : ""}`);
128
+ const retry = (await p.confirm({
129
+ message: "Configured it in another terminal? Check again:",
130
+ initialValue: true,
131
+ }));
132
+ if (p.isCancel(retry) || !retry) {
133
+ p.log.error("AWS credentials are required to deploy. Re-run the installer once they're set up — your answers are saved.");
134
+ process.exit(1);
135
+ }
136
+ }
137
+ }
138
+ }
139
+ async function ensurePortableClaudeAuth(homeDir) {
140
+ void homeDir;
141
+ if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
142
+ if (validateClaudeOauthToken(process.env.CLAUDE_CODE_OAUTH_TOKEN)) {
143
+ p.log.success("Verified Claude OAuth token from CLAUDE_CODE_OAUTH_TOKEN.");
144
+ return normalizeClaudeOauthToken(process.env.CLAUDE_CODE_OAUTH_TOKEN);
145
+ }
146
+ p.log.warn("CLAUDE_CODE_OAUTH_TOKEN is set but did not validate. Falling back to local Claude auth checks.");
147
+ }
148
+ p.log.info("Running `claude setup-token` to create a long-lived token for ECS...");
149
+ let extractedToken = "";
150
+ try {
151
+ const transcriptPath = path.join(os.tmpdir(), `citio-claude-setup-token-${Date.now()}.log`);
152
+ execFileSync("script", ["-q", transcriptPath, "claude", "setup-token"], {
153
+ stdio: "inherit",
154
+ timeout: 300000,
155
+ });
156
+ extractedToken = extractClaudeOauthTokenFromTranscript(transcriptPath) || "";
157
+ }
158
+ catch {
159
+ p.log.error("`claude setup-token` failed. Re-run `citio` later or use API key auth.");
160
+ process.exit(1);
161
+ }
162
+ if (extractedToken && validateClaudeOauthToken(extractedToken)) {
163
+ p.log.success("Captured and verified Claude OAuth token from `claude setup-token`.");
164
+ return extractedToken;
165
+ }
166
+ const token = (await p.text({
167
+ message: "Paste the CLAUDE_CODE_OAUTH_TOKEN that Claude just showed you:",
168
+ placeholder: "sk-ant-oat01-...",
169
+ }));
170
+ if (p.isCancel(token))
171
+ process.exit(0);
172
+ const normalizedToken = normalizeClaudeOauthToken(token);
173
+ if (!validateClaudeOauthToken(normalizedToken)) {
174
+ p.log.error("That Claude OAuth token did not validate from a clean environment. Re-run `claude setup-token` and paste the full token exactly.");
175
+ process.exit(1);
176
+ }
177
+ p.log.success("Verified portable Claude OAuth token. It will be deployed as CLAUDE_CODE_OAUTH_TOKEN.");
178
+ return normalizedToken;
179
+ }
180
+ async function reuseOrPromptSecret(options) {
181
+ const value = await p.password({
182
+ message: options.existingValue
183
+ ? `${options.message} (press Enter to keep saved value)`
184
+ : options.message,
185
+ validate: (input) => {
186
+ if (!input && options.existingValue) {
187
+ return undefined;
188
+ }
189
+ return options.validate ? options.validate(input) : undefined;
190
+ },
191
+ });
192
+ if (p.isCancel(value))
193
+ process.exit(0);
194
+ if (!value && options.existingValue) {
195
+ return options.existingValue;
196
+ }
197
+ return value;
198
+ }
199
+ function copyToClipboard(text) {
200
+ try {
201
+ if (process.platform === "darwin") {
202
+ execSync("pbcopy", { input: text, stdio: ["pipe", "pipe", "pipe"] });
203
+ return true;
204
+ }
205
+ if (process.platform === "linux") {
206
+ execSync("xclip -selection clipboard", { input: text, stdio: ["pipe", "pipe", "pipe"] });
207
+ return true;
208
+ }
209
+ }
210
+ catch {
211
+ // Best-effort only — the manifest is printed either way.
212
+ }
213
+ return false;
214
+ }
215
+ async function promptSlackTokensManually(savedState) {
216
+ p.note("You'll create the Slack app by pasting a ready-made “manifest” —\n" +
217
+ "a JSON blueprint of the app that sets every permission, event\n" +
218
+ "subscription, and Socket Mode for you. No scope-hunting needed.\n" +
219
+ "We'll put it on your clipboard right before you need it.", "Guided Slack setup (~3 minutes)");
220
+ // Sign-in gate BEFORE opening the create-app page — the create dialog renders an
221
+ // empty workspace dropdown (no error) when the browser has no Slack session.
222
+ const CREATE_APP_URL = "https://api.slack.com/apps?new_app=1";
223
+ const signedIn = (await p.confirm({
224
+ message: "Are you signed in to Slack in your default browser? (the create-app page needs a live session)",
225
+ initialValue: true,
226
+ }));
227
+ if (p.isCancel(signedIn))
228
+ process.exit(0);
229
+ if (!signedIn) {
230
+ openBrowser("https://slack.com/signin");
231
+ p.note("1. Sign in at https://slack.com/signin (window just opened)\n" +
232
+ "2. Once you're in, come back here and continue —\n" +
233
+ ` we'll open the create-app page for you: ${CREATE_APP_URL}`, "Sign in to Slack first");
234
+ const ready = (await p.confirm({ message: "Signed in? Continue to app creation:", initialValue: true }));
235
+ if (p.isCancel(ready) || !ready)
236
+ process.exit(0);
237
+ }
238
+ // Copy the manifest HERE — immediately before the step that pastes it — so the
239
+ // clipboard is fresh and the instruction sits next to the action.
240
+ const manifestJson = JSON.stringify(buildCitioSlackManifest(), null, 2);
241
+ const manifestPath = path.join(process.cwd(), "slack-app-manifest.json");
242
+ writeFileSync(manifestPath, manifestJson + "\n");
243
+ const copied = copyToClipboard(manifestJson);
244
+ if (copied) {
245
+ p.log.success(`✔ App manifest copied to your clipboard (backup: ${manifestPath})`);
246
+ }
247
+ else {
248
+ p.log.info(`Copy the app manifest from ${manifestPath}, or copy the block below:`);
249
+ console.log("–––––––––––––––––––––––––––––––––––––––––––––");
250
+ console.log(manifestJson);
251
+ console.log("–––––––––––––––––––––––––––––––––––––––––––––");
252
+ }
253
+ const openedCreate = openBrowser(CREATE_APP_URL);
254
+ p.note(`${step("STEP 1 — Create the app")} (${openedCreate ? "a browser window just opened" : `open ${url(CREATE_APP_URL)}`})\n` +
255
+ ` • If you get signed out at any point: sign in at slack.com/signin,\n` +
256
+ ` then return to ${CREATE_APP_URL}\n` +
257
+ " • Choose “From a manifest” → pick your workspace → Next\n" +
258
+ " • Select the JSON tab and paste the manifest we just copied\n" +
259
+ ` (${copied ? "it's on your clipboard — just press Cmd+V / Ctrl+V" : `copy it from ${manifestPath}`};\n` +
260
+ " replace whatever placeholder JSON Slack shows) → Next → Create\n" +
261
+ "\n" +
262
+ `${step("STEP 2 — Install it")}\n` +
263
+ " • On the app page: “Install App” (left sidebar) →\n" +
264
+ " the green “Install to <your workspace>” button → review → Allow\n" +
265
+ "\n" +
266
+ `${step("STEP 3 — Copy the Bot Token")} (${val("xoxb-…")})\n` +
267
+ " • Left sidebar → “OAuth & Permissions”\n" +
268
+ " • Copy “Bot User OAuth Token” — it starts with xoxb-\n" +
269
+ "\n" +
270
+ `${step("STEP 4 — Create + copy the App Token")} (${val("xapp-…")})\n` +
271
+ " • Left sidebar → “Basic Information” → scroll to “App-Level Tokens”\n" +
272
+ " • “Generate Token and Scopes” → name it citio-socket\n" +
273
+ " • “Add Scope” → connections:write → Generate\n" +
274
+ " • Copy the token — it starts with xapp-", "Slack app — 4 steps");
275
+ const slackBotToken = await reuseOrPromptSecret({
276
+ message: "Paste the Bot Token (xoxb-…) from OAuth & Permissions:",
277
+ existingValue: savedState.slackBotToken,
278
+ validate: validateSlackBotToken,
279
+ });
280
+ const slackAppToken = await reuseOrPromptSecret({
281
+ message: "Paste the App Token (xapp-…) from Basic Information → App-Level Tokens:",
282
+ existingValue: savedState.slackAppToken,
283
+ validate: validateSlackAppToken,
284
+ });
285
+ return { slackBotToken, slackAppToken };
286
+ }
287
+ async function collectSlackTokens(savedState) {
288
+ const hasSavedTokens = Boolean(savedState.slackBotToken && savedState.slackAppToken);
289
+ // Smart reuse: live-check saved tokens so we only offer (and preselect) them
290
+ // when they still work — and show which workspace they belong to.
291
+ let savedTokensValid = false;
292
+ let savedTokensHint = "";
293
+ if (hasSavedTokens) {
294
+ const checkSpinner = p.spinner();
295
+ checkSpinner.start("Checking your saved Slack tokens...");
296
+ const identity = await testSlackBotToken(savedState.slackBotToken);
297
+ if (identity.ok) {
298
+ savedTokensValid = true;
299
+ savedTokensHint = `still valid — workspace “${identity.team || "?"}”, bot @${identity.botUser || "citio"}`;
300
+ checkSpinner.stop(`Saved Slack tokens are valid (workspace “${identity.team || "?"}”).`);
301
+ }
302
+ else {
303
+ checkSpinner.stop("Saved Slack tokens no longer validate — the app may have been deleted or the token revoked.");
304
+ }
305
+ }
306
+ const setupMode = (await p.select({
307
+ message: "How should Slack be configured?",
308
+ initialValue: savedTokensValid ? "reuse" : "manual",
309
+ options: [
310
+ ...(savedTokensValid ? [{
311
+ value: "reuse",
312
+ label: "Reuse saved Slack tokens (recommended)",
313
+ hint: savedTokensHint,
314
+ }] : []),
315
+ {
316
+ value: "manual",
317
+ label: savedTokensValid ? "Guided setup — create a new app" : "Guided setup (recommended)",
318
+ hint: "Paste a ready-made app manifest into Slack — we walk you through every click",
319
+ },
320
+ {
321
+ value: "automatic",
322
+ label: "Automatic app creation (advanced)",
323
+ hint: "Citio creates the app via the Slack API — needs a 12-hour app configuration token (xoxe…)",
324
+ },
325
+ ],
326
+ }));
327
+ if (p.isCancel(setupMode))
328
+ process.exit(0);
329
+ if (setupMode === "reuse") {
330
+ return {
331
+ slackBotToken: savedState.slackBotToken,
332
+ slackAppToken: savedState.slackAppToken,
333
+ };
334
+ }
335
+ if (setupMode === "manual") {
336
+ return promptSlackTokensManually(savedState);
337
+ }
338
+ const openedAppsPage = openBrowser("https://api.slack.com/apps");
339
+ p.note("Citio will create the Slack app and configure every scope for you.\n" +
340
+ "It needs a one-time “app configuration token” from Slack:\n" +
341
+ "\n" +
342
+ " 0. Sign in to Slack in your browser first (https://slack.com/signin),\n" +
343
+ " then return to https://api.slack.com/apps — the token generator\n" +
344
+ " only lists workspaces you're signed in to\n" +
345
+ ` 1. ${openedAppsPage ? "In the browser window that just opened" : "Open https://api.slack.com/apps"},\n` +
346
+ " scroll to the bottom section “Your App Configuration Tokens”\n" +
347
+ " 2. Click “Generate Token” and pick the workspace Citio should join\n" +
348
+ " 3. Copy the ACCESS token — it starts with xoxe-\n" +
349
+ "\n" +
350
+ "(This token only creates the app and expires after 12 hours —\n" +
351
+ "it is not stored. You'll still approve the install and create one\n" +
352
+ "Socket Mode token in the browser afterwards.)", "Automatic Slack setup (~2 minutes)");
353
+ const configToken = await reuseOrPromptSecret({
354
+ message: "Paste the app configuration token (xoxe…):",
355
+ validate: validateSlackConfigToken,
356
+ });
357
+ const spinner = p.spinner();
358
+ spinner.start("Creating the Slack app via manifest...");
359
+ let slackApp;
360
+ try {
361
+ slackApp = await createCitioSlackApp(configToken, { appName: "Citio" });
362
+ }
363
+ catch (error) {
364
+ spinner.stop("Slack app creation failed.");
365
+ p.log.error(error instanceof Error ? error.message : String(error));
366
+ const fallback = (await p.confirm({
367
+ message: "Slack app automation failed. Fall back to manual token entry?",
368
+ initialValue: true,
369
+ }));
370
+ if (p.isCancel(fallback))
371
+ process.exit(0);
372
+ if (!fallback) {
373
+ process.exit(1);
374
+ }
375
+ return promptSlackTokensManually(savedState);
376
+ }
377
+ spinner.stop(`Created Slack app ${slackApp.appId}.`);
378
+ const openedAuthUrl = openBrowser(slackApp.oauthAuthorizeUrl);
379
+ const openedSettingsUrl = openBrowser(slackApp.settingsUrl);
380
+ p.note(`STEP 1 — Approve the install${openedAuthUrl ? " (a browser window just opened)" : ""}\n` +
381
+ (openedAuthUrl ? "" : ` • Open ${slackApp.oauthAuthorizeUrl}\n`) +
382
+ " • Click “Allow” to install the app to your workspace\n" +
383
+ "\n" +
384
+ `STEP 2 — Copy the Bot Token (xoxb-…)\n` +
385
+ ` • In the app settings${openedSettingsUrl ? " window that opened" : ` at ${slackApp.settingsUrl}`}:\n` +
386
+ " • Left sidebar → “OAuth & Permissions”\n" +
387
+ " • Copy “Bot User OAuth Token” — it starts with xoxb-\n" +
388
+ "\n" +
389
+ "STEP 3 — Create + copy the App Token (xapp-…)\n" +
390
+ " • Left sidebar → “Basic Information” → scroll to “App-Level Tokens”\n" +
391
+ " • “Generate Token and Scopes” → name it citio-socket\n" +
392
+ " • “Add Scope” → connections:write → Generate\n" +
393
+ " • Copy the token — it starts with xapp-", "Finish in the browser — 3 steps");
394
+ const slackBotToken = await reuseOrPromptSecret({
395
+ message: "Paste the Bot Token (xoxb-…) from OAuth & Permissions:",
396
+ existingValue: savedState.slackBotToken,
397
+ validate: validateSlackBotToken,
398
+ });
399
+ const slackAppToken = await reuseOrPromptSecret({
400
+ message: "Paste the App Token (xapp-…) from Basic Information → App-Level Tokens:",
401
+ existingValue: savedState.slackAppToken,
402
+ validate: validateSlackAppToken,
403
+ });
404
+ return { slackBotToken, slackAppToken };
405
+ }
406
+ async function collectConfig() {
407
+ const savedState = await loadSavedInstallerState(process.cwd());
408
+ const hasSavedState = Boolean(savedState.provider ||
409
+ savedState.authMethod ||
410
+ savedState.slackBotToken ||
411
+ savedState.slackAppToken ||
412
+ savedState.githubToken ||
413
+ savedState.claudeOauthToken ||
414
+ savedState.repos.length > 0);
415
+ if (hasSavedState) {
416
+ p.log.info(`Found existing installer state. Reusing defaults from app config and ${savedState.secretBackend}.`);
417
+ }
418
+ // Provider selection
419
+ const provider = (await p.select({
420
+ message: "Which agent engine?",
421
+ initialValue: savedState.provider,
422
+ options: [
423
+ {
424
+ value: "codex",
425
+ label: "Codex (OpenAI)",
426
+ hint: "OpenAI / ChatGPT Go, Plus or Pro",
427
+ },
428
+ {
429
+ value: "claude",
430
+ label: "Claude Code (Anthropic)",
431
+ },
432
+ ],
433
+ }));
434
+ if (p.isCancel(provider))
435
+ process.exit(0);
436
+ // Provider auth method
437
+ const authMethod = (await p.select({
438
+ message: "How should the agent authenticate?",
439
+ initialValue: savedState.authMethod,
440
+ options: [
441
+ {
442
+ value: "oauth",
443
+ label: "OAuth login (recommended)",
444
+ hint: provider === "claude"
445
+ ? "runs 'claude auth login' and, if needed, 'claude setup-token' for a portable long-lived token"
446
+ : "runs 'codex login --device-auth' — uses your OpenAI account",
447
+ },
448
+ {
449
+ value: "api_key",
450
+ label: "API key",
451
+ hint: "pay-per-token, enter key manually",
452
+ },
453
+ ],
454
+ }));
455
+ if (p.isCancel(authMethod))
456
+ process.exit(0);
457
+ let providerApiKey = "";
458
+ let claudeOauthToken = "";
459
+ if (authMethod === "oauth") {
460
+ // Check if already authenticated locally
461
+ const homeDir = process.env.HOME || "";
462
+ const authPath = `${homeDir}/.codex/auth.json`;
463
+ if (provider === "claude") {
464
+ p.note(`Claude uses a long-lived headless token (starts with ${val("sk-ant-oat01-…")})\n` +
465
+ "minted from your Claude Max/Pro login. To get one:\n" +
466
+ "\n" +
467
+ " 1. Open a SECOND terminal window\n" +
468
+ ` 2. Run: ${cmd("claude setup-token")}\n` +
469
+ " 3. A browser opens — sign in with your Claude account and approve\n" +
470
+ " 4. Copy the full token it prints (sk-ant-oat01-…) and paste it here\n" +
471
+ "\n" +
472
+ "It's a static credential (no refresh rotation), so it deploys safely\n" +
473
+ "as an environment variable — no in-container login step like Codex.", "Claude authentication — 4 steps");
474
+ if (savedState.claudeOauthToken && validateClaudeOauthToken(savedState.claudeOauthToken)) {
475
+ const token = await reuseOrPromptSecret({
476
+ message: "Claude OAuth token — press Enter to reuse your saved one, or paste a fresh `claude setup-token` result:",
477
+ existingValue: savedState.claudeOauthToken,
478
+ });
479
+ claudeOauthToken = normalizeClaudeOauthToken(token);
480
+ }
481
+ else {
482
+ p.log.info("No saved token — running `claude setup-token` for you now (follow its browser prompt)...");
483
+ claudeOauthToken = await ensurePortableClaudeAuth(homeDir);
484
+ }
485
+ }
486
+ else {
487
+ // Codex: the CONTAINER authenticates itself during deploy (device auth),
488
+ // owning its own OAuth token family. No local login is needed or used —
489
+ // sharing the laptop's auth.json caused single-use refresh-token races.
490
+ void authPath;
491
+ p.note("Codex will sign in INSIDE the container during deploy:\n" +
492
+ "the deploy output will show an OpenAI device-auth URL + code —\n" +
493
+ "open it in your browser and approve (uses your ChatGPT Go/Plus/Pro login).\n" +
494
+ "The container keeps its own credentials on EFS from then on.", "Codex authentication");
495
+ }
496
+ }
497
+ else {
498
+ providerApiKey = await reuseOrPromptSecret({
499
+ message: provider === "codex"
500
+ ? "Enter your OpenAI API key (OPENAI_API_KEY):"
501
+ : "Enter your Anthropic API key (ANTHROPIC_API_KEY):",
502
+ existingValue: provider === "codex" ? savedState.openAiApiKey : savedState.anthropicApiKey,
503
+ });
504
+ }
505
+ // Slack setup
506
+ const { slackBotToken, slackAppToken } = await collectSlackTokens(savedState);
507
+ p.note("Pick the channel where Citio should listen for @mentions:\n" +
508
+ " 1. In Slack, click the channel name at the top of the channel\n" +
509
+ " 2. Scroll to the bottom of the About tab\n" +
510
+ " 3. Copy the “Channel ID” — it starts with C\n" +
511
+ "\n" +
512
+ "After deploy, run /invite @citio in that channel so the bot can see it.\n" +
513
+ "(DMs to the bot work everywhere — this only scopes channel mentions.)", "Find your Channel ID");
514
+ const slackChannelId = (await p.text({
515
+ message: savedState.slackChannelId
516
+ ? "Slack Channel ID (press Enter to keep saved value):"
517
+ : "Slack Channel ID (starts with C):",
518
+ placeholder: "C0123456789",
519
+ defaultValue: savedState.slackChannelId,
520
+ initialValue: savedState.slackChannelId,
521
+ }));
522
+ if (p.isCancel(slackChannelId))
523
+ process.exit(0);
524
+ // GitHub token
525
+ p.note("Citio uses this to clone your repos and open pull requests.\n" +
526
+ "\n" +
527
+ "Fine-grained token (recommended):\n" +
528
+ " 1. Open https://github.com/settings/personal-access-tokens/new\n" +
529
+ " 2. “Repository access” → Only select repositories → pick the repos\n" +
530
+ " Citio will work on\n" +
531
+ " 3. “Repository permissions” →\n" +
532
+ " • Contents: Read and write\n" +
533
+ " • Pull requests: Read and write\n" +
534
+ " (Metadata: Read is added automatically)\n" +
535
+ " 4. Generate token → copy it (starts with github_pat_)\n" +
536
+ "\n" +
537
+ "A classic token also works: https://github.com/settings/tokens/new\n" +
538
+ "with the “repo” scope (starts with ghp_).", "GitHub token — 4 steps");
539
+ const githubToken = await reuseOrPromptSecret({
540
+ message: "Paste your GitHub Personal Access Token:",
541
+ existingValue: savedState.githubToken,
542
+ });
543
+ // Repos — fetch available repos from GitHub using the PAT
544
+ let repos = [];
545
+ const repoSpinner = p.spinner();
546
+ repoSpinner.start("Fetching repos your token has access to...");
547
+ try {
548
+ // List repos accessible by the token (handles both classic and fine-grained PATs)
549
+ const repoJson = execSync(`curl -s -H "Authorization: token ${githubToken}" "https://api.github.com/user/repos?per_page=100&sort=updated&affiliation=owner,collaborator,organization_member" 2>/dev/null`, { encoding: "utf-8", timeout: 15000 });
550
+ const repoList = JSON.parse(repoJson);
551
+ if (Array.isArray(repoList) && repoList.length > 0) {
552
+ repoSpinner.stop(`Found ${repoList.length} repos`);
553
+ const selectedRepos = (await p.multiselect({
554
+ message: "Select repos for Citio to work on (use space to select, enter to confirm):",
555
+ options: repoList.slice(0, 50).map((r) => ({
556
+ value: r.clone_url,
557
+ label: r.full_name,
558
+ hint: `${r.private ? "private" : "public"} · ${r.default_branch} · updated ${r.updated_at.split("T")[0]}`,
559
+ })),
560
+ initialValues: savedState.repos.map((repo) => repo.url),
561
+ required: true,
562
+ }));
563
+ if (p.isCancel(selectedRepos))
564
+ process.exit(0);
565
+ repos = selectedRepos.map((url) => {
566
+ const match = repoList.find((r) => r.clone_url === url);
567
+ return { url, branch: match?.default_branch || "main" };
568
+ });
569
+ }
570
+ else {
571
+ repoSpinner.stop("No repos found (token may not have repo access)");
572
+ }
573
+ }
574
+ catch {
575
+ repoSpinner.stop("Could not fetch repos from GitHub");
576
+ }
577
+ // Fallback to manual entry if auto-fetch failed or returned nothing
578
+ if (repos.length === 0) {
579
+ const repoInput = (await p.text({
580
+ message: savedState.repos.length > 0
581
+ ? "Repository URL(s) (comma-separated, press Enter to keep saved value):"
582
+ : "Repository URL(s) (comma-separated):",
583
+ placeholder: "https://github.com/org/repo.git",
584
+ defaultValue: savedState.repos.map((repo) => repo.url).join(", "),
585
+ initialValue: savedState.repos.map((repo) => repo.url).join(", "),
586
+ }));
587
+ if (p.isCancel(repoInput))
588
+ process.exit(0);
589
+ repos = repoInput.split(",").map((url) => ({
590
+ url: url.trim(),
591
+ branch: "main",
592
+ }));
593
+ }
594
+ // Rules — behaviour guardrails injected into every task prompt the agent runs.
595
+ const DEFAULT_RULES = [
596
+ "Always create PRs for code changes. Never push directly to main.",
597
+ "When investigating bugs, check logs first before making code changes.",
598
+ "Report findings back to the team with clear summaries.",
599
+ ];
600
+ const startingRules = savedState.rules.length > 0 ? savedState.rules : DEFAULT_RULES;
601
+ p.note("Rules are plain-English guardrails the agent must follow on every\n" +
602
+ "task — they're included in each prompt it receives. Current rules:\n" +
603
+ "\n" +
604
+ startingRules.map((rule, i) => ` ${i + 1}. ${rule}`).join("\n"), savedState.rules.length > 0 ? "Agent rules (saved)" : "Agent rules (defaults)");
605
+ const rulesChoice = (await p.select({
606
+ message: "How do you want to set the agent rules?",
607
+ options: [
608
+ {
609
+ value: "keep",
610
+ label: savedState.rules.length > 0 ? "Keep my saved rules" : "Use the defaults (recommended)",
611
+ hint: "you can edit citio.yaml later",
612
+ },
613
+ { value: "add", label: "Keep them and add more", hint: "entered one at a time" },
614
+ { value: "replace", label: "Write my own from scratch", hint: "entered one at a time" },
615
+ ],
616
+ }));
617
+ if (p.isCancel(rulesChoice))
618
+ process.exit(0);
619
+ let rules = [...startingRules];
620
+ if (rulesChoice !== "keep") {
621
+ if (rulesChoice === "replace")
622
+ rules = [];
623
+ for (;;) {
624
+ const rule = (await p.text({
625
+ message: `Rule ${rules.length + 1} (press Enter on an empty line to finish):`,
626
+ placeholder: rules.length === 0 ? "Always create PRs. Never push to main." : "",
627
+ }));
628
+ if (p.isCancel(rule))
629
+ process.exit(0);
630
+ const trimmed = (rule || "").trim();
631
+ if (!trimmed)
632
+ break;
633
+ rules.push(trimmed);
634
+ p.log.success(`Added: “${trimmed}”`);
635
+ }
636
+ if (rules.length === 0) {
637
+ p.log.info("No rules entered — using the defaults.");
638
+ rules = [...DEFAULT_RULES];
639
+ }
640
+ }
641
+ // Skills
642
+ const skillChoices = (await p.multiselect({
643
+ message: "Install agent skills? Optional — all can be added later; skills run with the agent's full permissions (space to select, enter to confirm)",
644
+ options: Object.entries(SKILL_REGISTRY).map(([name, info]) => ({
645
+ value: name,
646
+ label: name,
647
+ hint: info.description,
648
+ })),
649
+ initialValues: savedState.skills,
650
+ required: false,
651
+ }));
652
+ if (p.isCancel(skillChoices))
653
+ process.exit(0);
654
+ const gitUserEmail = (await p.text({
655
+ message: savedState.gitUserEmail
656
+ ? "Git commit email override (press Enter to keep saved value, leave blank to skip):"
657
+ : "Git commit email override (optional, leave blank to skip):",
658
+ placeholder: "you@example.com",
659
+ defaultValue: savedState.gitUserEmail,
660
+ initialValue: savedState.gitUserEmail,
661
+ }));
662
+ if (p.isCancel(gitUserEmail))
663
+ process.exit(0);
664
+ // AWS config — state everything the deploy needs BEFORE asking any AWS question.
665
+ p.note("Citio deploys into YOUR AWS account. To make this smooth, have:\n" +
666
+ "\n" +
667
+ ` 1. AWS CLI v2 installed (${cmd("aws --version")})\n` +
668
+ " 2. A profile with working credentials —\n" +
669
+ ` • access key: ${cmd("aws configure")} (or ${cmd("aws configure --profile <name>")})\n` +
670
+ ` • or SSO: ${cmd("aws configure sso && aws sso login --profile <name>")}\n` +
671
+ " 3. Permissions to create: ECR repo · ECS cluster/service · IAM roles\n" +
672
+ " (citio-*, incl. iam:PassRole) · security group · CloudWatch logs · EFS\n" +
673
+ " • Personal account: the AdministratorAccess policy is simplest\n" +
674
+ " • Shared/work account: least-privilege policy in docs/AWS_SETUP.md\n" +
675
+ "\n" +
676
+ "You'll pick the profile next; we verify credentials and show WHICH\n" +
677
+ "account you're deploying to before anything is created.\n" +
678
+ `${warn("Cost note: the default always-on task is ~$70–90/month")} (scale-to-zero\n` +
679
+ "and teardown commands are in docs/AWS_SETUP.md; `citio destroy` removes it).", "AWS deployment — what your profile needs");
680
+ let detectedRegion = "";
681
+ try {
682
+ detectedRegion = execSync("aws configure get region 2>/dev/null", { encoding: "utf-8", stdio: "pipe" }).trim();
683
+ }
684
+ catch {
685
+ // No configured region — fall back below.
686
+ }
687
+ const regionDefault = savedState.awsRegion || detectedRegion || "us-east-1";
688
+ const awsRegion = (await p.text({
689
+ message: savedState.awsRegion
690
+ ? "AWS Region (press Enter to keep saved value):"
691
+ : detectedRegion
692
+ ? `AWS Region (detected ${detectedRegion} from your AWS CLI — press Enter to accept):`
693
+ : "AWS Region:",
694
+ placeholder: "us-east-1",
695
+ defaultValue: regionDefault,
696
+ initialValue: regionDefault,
697
+ }));
698
+ if (p.isCancel(awsRegion))
699
+ process.exit(0);
700
+ let awsProfile = "";
701
+ try {
702
+ const profiles = execSync("aws configure list-profiles 2>/dev/null || echo default", { encoding: "utf-8" })
703
+ .trim()
704
+ .split("\n");
705
+ if (profiles.length > 1) {
706
+ awsProfile = (await p.select({
707
+ message: "AWS Profile:",
708
+ initialValue: savedState.awsProfile,
709
+ options: profiles.map((profile) => ({
710
+ value: profile,
711
+ label: profile,
712
+ })),
713
+ }));
714
+ if (p.isCancel(awsProfile))
715
+ process.exit(0);
716
+ }
717
+ else {
718
+ awsProfile = profiles[0];
719
+ }
720
+ }
721
+ catch {
722
+ awsProfile = "default";
723
+ }
724
+ // Fail fast on credentials — better here than 10 minutes into the Docker build.
725
+ await ensureAwsCredentials(awsProfile);
726
+ // Codex OAuth stores the container's own credentials on EFS — not optional,
727
+ // so don't ask a question that only has one valid answer.
728
+ const efsRequiredForCodex = provider === "codex" && authMethod === "oauth";
729
+ let enableEfs;
730
+ if (efsRequiredForCodex) {
731
+ enableEfs = true;
732
+ p.log.info("EFS persistence: enabled automatically (Codex OAuth keeps its credentials there).");
733
+ }
734
+ else {
735
+ enableEfs = (await p.confirm({
736
+ message: "Enable EFS persistence for workspace, memory, and auth? Recommended for repo state across redeploys.",
737
+ initialValue: savedState.enableEfs ?? true,
738
+ }));
739
+ if (p.isCancel(enableEfs))
740
+ process.exit(0);
741
+ }
742
+ return {
743
+ provider,
744
+ authMethod,
745
+ providerApiKey,
746
+ claudeOauthToken,
747
+ slackBotToken,
748
+ slackAppToken,
749
+ slackChannelId,
750
+ githubToken,
751
+ repos,
752
+ rules,
753
+ skills: skillChoices,
754
+ gitUserEmail: gitUserEmail.trim(),
755
+ awsRegion,
756
+ awsProfile,
757
+ enableEfs,
758
+ };
759
+ }
760
+ function buildYamlConfig(config) {
761
+ const skillsDirectory = config.enableEfs
762
+ ? "/home/citio/workspace/.citio/skills/"
763
+ : "/workspace/.citio/skills/";
764
+ return {
765
+ name: "citio",
766
+ version: 1,
767
+ slack: {
768
+ bot_token: "${SLACK_BOT_TOKEN}",
769
+ app_token: "${SLACK_APP_TOKEN}",
770
+ channel_id: config.slackChannelId,
771
+ authorized_users: [],
772
+ },
773
+ engine: {
774
+ default_provider: config.provider,
775
+ max_session_duration_minutes: 60,
776
+ max_concurrent_sessions: 1,
777
+ auth_method: config.authMethod,
778
+ providers: {
779
+ codex: config.provider === "codex" && config.authMethod === "api_key"
780
+ ? { api_key: "${OPENAI_API_KEY}" } : {},
781
+ claude: config.provider === "claude" && config.authMethod === "api_key"
782
+ ? { api_key: "${ANTHROPIC_API_KEY}" } : {},
783
+ },
784
+ },
785
+ skills: {
786
+ installed: config.skills,
787
+ directory: skillsDirectory,
788
+ },
789
+ workspace: {
790
+ repos: config.repos,
791
+ rules: config.rules,
792
+ git: {
793
+ user_name: "Citio",
794
+ ...(config.gitUserEmail ? { user_email: config.gitUserEmail } : {}),
795
+ },
796
+ },
797
+ deploy: {
798
+ provider: "aws",
799
+ aws: {
800
+ region: config.awsRegion,
801
+ ecr_repo: "citio",
802
+ ecs_cluster: "citio",
803
+ ecs_service: "citio",
804
+ profile: config.awsProfile,
805
+ enable_efs: config.enableEfs,
806
+ task_cpu: 2048,
807
+ task_memory: 8192,
808
+ ephemeral_storage_gb: 100,
809
+ },
810
+ },
811
+ };
812
+ }
813
+ function getServiceNetworkConfig(region, profileFlag) {
814
+ const subnetId = execSync(`aws ecs describe-services --cluster citio --services citio --region ${region} ${profileFlag} --query 'services[0].networkConfiguration.awsvpcConfiguration.subnets[0]' --output text`, { encoding: "utf-8", stdio: "pipe" }).trim();
815
+ const securityGroupId = execSync(`aws ecs describe-services --cluster citio --services citio --region ${region} ${profileFlag} --query 'services[0].networkConfiguration.awsvpcConfiguration.securityGroups[0]' --output text`, { encoding: "utf-8", stdio: "pipe" }).trim();
816
+ return { subnetId, securityGroupId };
817
+ }
818
+ function waitForTaskStop(taskArn, region, profileFlag, maxAttempts, intervalSeconds) {
819
+ const taskId = taskArn.split("/").pop() || taskArn;
820
+ for (let i = 0; i < maxAttempts; i++) {
821
+ sleep(intervalSeconds);
822
+ const status = execSync(`aws ecs describe-tasks --cluster citio --tasks "${taskArn}" --region ${region} ${profileFlag} --query 'tasks[0].lastStatus' --output text`, { encoding: "utf-8", stdio: "pipe" }).trim();
823
+ if (status === "STOPPED") {
824
+ const exitCode = execSync(`aws ecs describe-tasks --cluster citio --tasks "${taskArn}" --region ${region} ${profileFlag} --query 'tasks[0].containers[0].exitCode' --output text`, { encoding: "utf-8", stdio: "pipe" }).trim();
825
+ return { exitCode, taskId };
826
+ }
827
+ }
828
+ return { exitCode: "", taskId };
829
+ }
830
+ function printNewCloudWatchLines(logGroup, logStream, region, profileFlag, seen) {
831
+ try {
832
+ const output = execSync(`aws logs get-log-events --log-group-name ${logGroup} --log-stream-name ${logStream} --region ${region} ${profileFlag} --limit 50 --query 'events[].message' --output text`, { encoding: "utf-8", stdio: "pipe", timeout: 15000 }).trim();
833
+ if (!output) {
834
+ return;
835
+ }
836
+ for (const message of output.split("\t")) {
837
+ const trimmed = message.trim();
838
+ if (!trimmed || seen.has(trimmed)) {
839
+ continue;
840
+ }
841
+ seen.add(trimmed);
842
+ p.log.info(trimmed);
843
+ }
844
+ }
845
+ catch {
846
+ // Best effort only while the task is starting up.
847
+ }
848
+ }
849
+ function startCodexAuthSetupTask(params) {
850
+ const containerName = params.mode === "device_auth" ? "codex-auth" : "auth-setup";
851
+ // efs_init: a fresh EFS filesystem's root dir is root-owned and mounts OVER
852
+ // /home/citio, shadowing the image's ownership — without this prep the runtime
853
+ // user (citio = uid 1001; node:22-slim ships "node" at 1000) gets EACCES on
854
+ // auth.json and crashes on mkdir /home/citio/workspace (verified in prod).
855
+ // Runs unconditionally for every EFS deploy, both providers.
856
+ //
857
+ // device_auth: runs `codex login --device-auth` INSIDE the container as the
858
+ // runtime user, so the container owns its own OAuth token family. (The old
859
+ // "upload local auth.json" path was removed: OpenAI refresh tokens are
860
+ // single-use, so a copied family meant the laptop and container raced to
861
+ // refresh the same token — refresh_token_reused corruption, verified in prod.)
862
+ const command = params.mode === "device_auth"
863
+ ? "mkdir -p /home/citio/.codex && codex login --device-auth"
864
+ : "mkdir -p /home/citio/.codex /home/citio/workspace && chown -R 1001:1001 /home/citio && echo EFS_INIT_OK";
865
+ const taskDef = JSON.stringify({
866
+ family: params.mode === "device_auth" ? "citio-codex-auth" : "citio-efs-init",
867
+ networkMode: "awsvpc",
868
+ requiresCompatibilities: ["FARGATE"],
869
+ cpu: "512",
870
+ memory: "1024",
871
+ executionRoleArn: `arn:aws:iam::${params.accountId}:role/citio-task-execution`,
872
+ taskRoleArn: `arn:aws:iam::${params.accountId}:role/citio-task-execution`,
873
+ volumes: [{
874
+ name: "citio-home",
875
+ efsVolumeConfiguration: {
876
+ fileSystemId: params.efsFileSystemId,
877
+ rootDirectory: "/",
878
+ transitEncryption: "ENABLED",
879
+ },
880
+ }],
881
+ containerDefinitions: [{
882
+ name: containerName,
883
+ image: params.mode === "device_auth" ? `${params.ecrUri}:latest` : "alpine:latest",
884
+ essential: true,
885
+ entryPoint: params.mode === "device_auth" ? ["sh", "-lc"] : undefined,
886
+ command: params.mode === "device_auth" ? [command] : ["sh", "-c", command],
887
+ environment: [
888
+ { name: "HOME", value: "/home/citio" },
889
+ ],
890
+ mountPoints: [{ sourceVolume: "citio-home", containerPath: "/home/citio", readOnly: false }],
891
+ logConfiguration: {
892
+ logDriver: "awslogs",
893
+ options: {
894
+ "awslogs-group": "/ecs/citio",
895
+ "awslogs-region": params.region,
896
+ "awslogs-stream-prefix": "auth-setup",
897
+ "awslogs-create-group": "true",
898
+ },
899
+ },
900
+ }],
901
+ });
902
+ writeFileSync("/tmp/citio-auth-task.json", taskDef);
903
+ const revision = execSync(`aws ecs register-task-definition --cli-input-json file:///tmp/citio-auth-task.json --region ${params.region} ${params.profileFlag} --query 'taskDefinition.revision' --output text`, { encoding: "utf-8", stdio: "pipe" }).trim();
904
+ const taskArn = execSync(`aws ecs run-task --cluster citio --task-definition "${params.mode === "device_auth" ? "citio-codex-auth" : "citio-efs-init"}:${revision}" --launch-type FARGATE --network-configuration "awsvpcConfiguration={subnets=[${params.subnetId}],securityGroups=[${params.securityGroupId}],assignPublicIp=ENABLED}" --region ${params.region} ${params.profileFlag} --query 'tasks[0].taskArn' --output text`, { encoding: "utf-8", stdio: "pipe" }).trim();
905
+ const taskId = taskArn.split("/").pop() || taskArn;
906
+ return {
907
+ taskArn,
908
+ taskId,
909
+ logStream: `auth-setup/${containerName}/${taskId}`,
910
+ };
911
+ }
912
+ function writeConfigFile(config) {
913
+ const yamlConfig = buildYamlConfig(config);
914
+ writeFileSync("citio.yaml", stringify(yamlConfig), "utf-8");
915
+ chmodSync("citio.yaml", 0o600);
916
+ }
917
+ function installSkills(skills, githubToken) {
918
+ if (skills.length === 0)
919
+ return;
920
+ const skillsDir = ".citio/skills";
921
+ const results = installSkillsTo(skills, skillsDir, { ghToken: githubToken });
922
+ for (const result of results) {
923
+ if (result.status === "installed") {
924
+ p.log.success(`Installed skill: ${result.skill}`);
925
+ }
926
+ else if (result.status === "already-present") {
927
+ p.log.info(`Skill already installed: ${result.skill}`);
928
+ }
929
+ else {
930
+ p.log.warn(`Failed to install ${result.skill} — you can install it manually later.${result.error ? ` (${result.error.split("\n")[0]})` : ""}`);
931
+ }
932
+ }
933
+ }
934
+ async function deployToAws(config) {
935
+ const s = p.spinner();
936
+ const profileFlag = config.awsProfile
937
+ ? `--profile ${config.awsProfile}`
938
+ : "";
939
+ const region = config.awsRegion;
940
+ // All build/docker commands must run from the Citio project directory
941
+ const projectDir = path.resolve(new URL(".", import.meta.url).pathname, "..", "..");
942
+ // 1. Get account ID
943
+ s.start("Getting AWS account info...");
944
+ const accountId = execSync(`aws sts get-caller-identity --query Account --output text ${profileFlag}`, { encoding: "utf-8" }).trim();
945
+ s.stop(`AWS Account: ${accountId}`);
946
+ // 2. Create ECR repository
947
+ s.start("Creating ECR repository...");
948
+ try {
949
+ execSync(`aws ecr create-repository --repository-name citio --region ${region} ${profileFlag} 2>/dev/null || true`, { encoding: "utf-8" });
950
+ }
951
+ catch {
952
+ // Already exists
953
+ }
954
+ const ecrUri = `${accountId}.dkr.ecr.${region}.amazonaws.com/citio`;
955
+ s.stop(`ECR: ${ecrUri}`);
956
+ // 3. Build and push Docker image
957
+ s.start("Building Docker image...");
958
+ // From a source checkout, compile dist/ first. When running from an installed
959
+ // package (e.g. `npx citio`), dist/ ships prebuilt and there is no src/ to compile.
960
+ if (existsSync(path.join(projectDir, "src"))) {
961
+ runDeployCommand("npm run build", "Failed to build the TypeScript application before Docker packaging.", { cwd: projectDir });
962
+ }
963
+ // Auth is handled at RUNTIME via env vars in the ECS task definition,
964
+ // never baked into the Docker image.
965
+ // Build for linux/amd64 (ECS Fargate requires it, even if building on ARM Mac)
966
+ runDeployCommand("docker build --platform linux/amd64 -t citio:latest .", "Docker build failed for the linux/amd64 ECS image.", { cwd: projectDir, timeout: 600000 });
967
+ s.stop("Docker image built");
968
+ s.start("Pushing to ECR...");
969
+ runDeployCommand(`aws ecr get-login-password --region ${region} ${profileFlag} | docker login --username AWS --password-stdin ${accountId}.dkr.ecr.${region}.amazonaws.com`, "Failed to log Docker into ECR.");
970
+ runDeployCommand(`docker tag citio:latest ${ecrUri}:latest`, "Failed to tag the Docker image for ECR.");
971
+ runDeployCommand(`docker push ${ecrUri}:latest`, "Failed to push the Docker image to ECR.", { timeout: 600000 });
972
+ s.stop("Image pushed to ECR");
973
+ // 4. Create ECS cluster
974
+ s.start("Setting up ECS cluster...");
975
+ try {
976
+ execSync(`aws ecs create-cluster --cluster-name citio --region ${region} ${profileFlag} 2>/dev/null || true`, { encoding: "utf-8" });
977
+ }
978
+ catch {
979
+ // Already exists
980
+ }
981
+ s.stop("ECS cluster ready");
982
+ // 5. Create EFS if enabled
983
+ let efsId = "";
984
+ if (config.enableEfs) {
985
+ s.start("Creating EFS filesystem for org memory...");
986
+ try {
987
+ const efsResult = execSync(`aws efs create-file-system --creation-token citio-memory --region ${region} ${profileFlag} --output json`, { encoding: "utf-8" });
988
+ const efsData = JSON.parse(efsResult);
989
+ efsId = efsData.FileSystemId;
990
+ s.stop(`EFS created: ${efsId}`);
991
+ }
992
+ catch {
993
+ s.stop("EFS already exists or creation failed. Continuing without EFS.");
994
+ }
995
+ }
996
+ // 6. Create task execution role
997
+ s.start("Setting up IAM roles...");
998
+ const trustPolicy = JSON.stringify({
999
+ Version: "2012-10-17",
1000
+ Statement: [
1001
+ {
1002
+ Effect: "Allow",
1003
+ Principal: { Service: "ecs-tasks.amazonaws.com" },
1004
+ Action: "sts:AssumeRole",
1005
+ },
1006
+ ],
1007
+ });
1008
+ try {
1009
+ execSync(`aws iam create-role --role-name citio-task-execution --assume-role-policy-document '${trustPolicy}' ${profileFlag} 2>/dev/null || true`, { encoding: "utf-8" });
1010
+ execSync(`aws iam attach-role-policy --role-name citio-task-execution --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy ${profileFlag} 2>/dev/null || true`, { encoding: "utf-8" });
1011
+ // Add CloudWatch Logs permissions (needed for awslogs-create-group)
1012
+ const logsPolicy = JSON.stringify({
1013
+ Version: "2012-10-17",
1014
+ Statement: [
1015
+ {
1016
+ Sid: "LogsWrite",
1017
+ Effect: "Allow",
1018
+ Action: ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents", "logs:DescribeLogStreams"],
1019
+ Resource: "arn:aws:logs:*:*:*"
1020
+ },
1021
+ {
1022
+ // query_logs MCP tool — a headline feature — needs READ, not just write.
1023
+ Sid: "LogsRead",
1024
+ Effect: "Allow",
1025
+ Action: ["logs:FilterLogEvents", "logs:GetLogEvents", "logs:DescribeLogGroups"],
1026
+ Resource: "arn:aws:logs:*:*:*"
1027
+ },
1028
+ {
1029
+ // Read-only deploy visibility — the app's own suggested prompt is
1030
+ // "Check deploy health"; without these the agent gets AccessDenied.
1031
+ Sid: "EcsReadOnlyVisibility",
1032
+ Effect: "Allow",
1033
+ Action: [
1034
+ "ecs:ListClusters", "ecs:ListServices", "ecs:ListTasks",
1035
+ "ecs:DescribeClusters", "ecs:DescribeServices", "ecs:DescribeTasks",
1036
+ "ecs:DescribeTaskDefinition", "ecs:ListTaskDefinitions"
1037
+ ],
1038
+ Resource: "*"
1039
+ },
1040
+ {
1041
+ // Read runtime secrets from Secrets Manager (tokens live there, not in
1042
+ // plaintext task-def env). Scoped to citio/* so it works regardless of
1043
+ // whether the secret exists yet when the policy is written.
1044
+ Sid: "SecretsRead",
1045
+ Effect: "Allow",
1046
+ Action: ["secretsmanager:GetSecretValue"],
1047
+ Resource: `arn:aws:secretsmanager:${region}:${accountId}:secret:citio/*`
1048
+ }
1049
+ ]
1050
+ });
1051
+ execSync(`aws iam put-role-policy --role-name citio-task-execution --policy-name citio-logs --policy-document '${logsPolicy}' ${profileFlag} 2>/dev/null || true`, { encoding: "utf-8" });
1052
+ }
1053
+ catch {
1054
+ // Roles may already exist
1055
+ }
1056
+ s.stop("IAM roles configured");
1057
+ // 6b. Store tokens in AWS Secrets Manager, NOT as plaintext task-def env vars
1058
+ // (those are readable via ecs:DescribeTaskDefinition). The secret is written
1059
+ // from a temp file so no token ever appears on a command line / in ps output.
1060
+ s.start("Storing tokens in AWS Secrets Manager...");
1061
+ const secretValues = {
1062
+ SLACK_BOT_TOKEN: config.slackBotToken,
1063
+ SLACK_APP_TOKEN: config.slackAppToken,
1064
+ GH_TOKEN: config.githubToken,
1065
+ };
1066
+ if (config.authMethod === "api_key" && config.providerApiKey) {
1067
+ secretValues[config.provider === "codex" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY"] = config.providerApiKey;
1068
+ }
1069
+ else if (config.provider === "claude" && config.claudeOauthToken) {
1070
+ secretValues.CLAUDE_CODE_OAUTH_TOKEN = config.claudeOauthToken;
1071
+ }
1072
+ const secretName = "citio/runtime";
1073
+ const secretFile = "/tmp/citio-secret.json";
1074
+ writeFileSync(secretFile, JSON.stringify(secretValues), { mode: 0o600 });
1075
+ let secretArn = "";
1076
+ try {
1077
+ secretArn = execSync(`aws secretsmanager create-secret --name ${secretName} --secret-string file://${secretFile} --region ${region} ${profileFlag} --query ARN --output text`, { encoding: "utf-8", stdio: "pipe" }).trim();
1078
+ }
1079
+ catch {
1080
+ // Already exists — rotate the value and read back the ARN.
1081
+ execSync(`aws secretsmanager put-secret-value --secret-id ${secretName} --secret-string file://${secretFile} --region ${region} ${profileFlag}`, { stdio: "pipe" });
1082
+ secretArn = execSync(`aws secretsmanager describe-secret --secret-id ${secretName} --region ${region} ${profileFlag} --query ARN --output text`, { encoding: "utf-8", stdio: "pipe" }).trim();
1083
+ }
1084
+ finally {
1085
+ try {
1086
+ unlinkSync(secretFile);
1087
+ }
1088
+ catch { /* already gone */ }
1089
+ }
1090
+ s.stop("Tokens stored in Secrets Manager (not in the task definition)");
1091
+ // 7. Register task definition
1092
+ s.start("Registering ECS task definition...");
1093
+ // Non-secret runtime config only — every token comes from Secrets Manager below.
1094
+ const envVars = [
1095
+ { name: "HOME", value: "/home/citio" },
1096
+ ];
1097
+ if (config.enableEfs) {
1098
+ envVars.push({ name: "CITIO_WORKSPACE", value: "/home/citio/workspace" }, { name: "CITIO_MEMORY", value: "/home/citio/memory" });
1099
+ }
1100
+ // Embed config as base64 so it doesn't need a file mount (contains only
1101
+ // ${ENV} placeholders for tokens — no secrets).
1102
+ const configYaml = readFileSync("citio.yaml", "utf-8");
1103
+ const configB64 = Buffer.from(configYaml).toString("base64");
1104
+ envVars.push({ name: "CITIO_CONFIG_B64", value: configB64 });
1105
+ // Secrets injected by ECS from Secrets Manager at container start.
1106
+ const secretEntries = Object.keys(secretValues).map((key) => ({
1107
+ name: key,
1108
+ valueFrom: `${secretArn}:${key}::`,
1109
+ }));
1110
+ // EFS is needed when the user enabled it, or for Codex OAuth (which stores
1111
+ // the container's own credentials there). Claude OAuth must NOT force EFS —
1112
+ // its token is a plain env var (resolving a non-existent filesystem here
1113
+ // used to hard-exit the claude+oauth+no-EFS deploy).
1114
+ const needsEfs = config.enableEfs || (config.provider === "codex" && config.authMethod === "oauth");
1115
+ const efsFileSystemId = needsEfs
1116
+ ? resolveEfsFileSystemId(region, profileFlag, efsId)
1117
+ : "";
1118
+ const homeVolume = needsEfs
1119
+ ? {
1120
+ name: "citio-home",
1121
+ efsVolumeConfiguration: {
1122
+ fileSystemId: efsFileSystemId,
1123
+ rootDirectory: "/",
1124
+ transitEncryption: "ENABLED",
1125
+ },
1126
+ }
1127
+ : null;
1128
+ const taskDef = {
1129
+ family: "citio",
1130
+ networkMode: "awsvpc",
1131
+ requiresCompatibilities: ["FARGATE"],
1132
+ cpu: "2048",
1133
+ memory: "8192",
1134
+ ephemeralStorage: { sizeInGiB: 100 },
1135
+ executionRoleArn: `arn:aws:iam::${accountId}:role/citio-task-execution`,
1136
+ taskRoleArn: `arn:aws:iam::${accountId}:role/citio-task-execution`,
1137
+ volumes: homeVolume ? [homeVolume] : undefined,
1138
+ containerDefinitions: [
1139
+ {
1140
+ name: "citio",
1141
+ image: `${ecrUri}:latest`,
1142
+ essential: true,
1143
+ portMappings: [{ containerPort: 3001, protocol: "tcp" }],
1144
+ environment: envVars,
1145
+ secrets: secretEntries,
1146
+ mountPoints: homeVolume
1147
+ ? [{ sourceVolume: "citio-home", containerPath: "/home/citio", readOnly: false }]
1148
+ : undefined,
1149
+ logConfiguration: {
1150
+ logDriver: "awslogs",
1151
+ options: {
1152
+ "awslogs-group": "/ecs/citio",
1153
+ "awslogs-region": region,
1154
+ "awslogs-stream-prefix": "ecs",
1155
+ "awslogs-create-group": "true",
1156
+ },
1157
+ },
1158
+ healthCheck: {
1159
+ command: [
1160
+ "CMD-SHELL",
1161
+ "curl -f http://localhost:3001/healthz || exit 1",
1162
+ ],
1163
+ interval: 30,
1164
+ timeout: 5,
1165
+ retries: 3,
1166
+ startPeriod: 30,
1167
+ },
1168
+ stopTimeout: 60,
1169
+ },
1170
+ ],
1171
+ };
1172
+ const taskDefPath = "/tmp/citio-task-def.json";
1173
+ writeFileSync(taskDefPath, JSON.stringify(taskDef, null, 2));
1174
+ runDeployCommand(`aws ecs register-task-definition --cli-input-json file://${taskDefPath} --region ${region} ${profileFlag}`, "Failed to register the ECS task definition.");
1175
+ s.stop("Task definition registered");
1176
+ // 8. Get default VPC and subnets
1177
+ s.start("Configuring networking...");
1178
+ let subnetId;
1179
+ let sgId;
1180
+ try {
1181
+ const vpcId = execSync(`aws ec2 describe-vpcs --filters "Name=isDefault,Values=true" --query "Vpcs[0].VpcId" --output text --region ${region} ${profileFlag}`, { encoding: "utf-8" }).trim();
1182
+ subnetId = execSync(`aws ec2 describe-subnets --filters "Name=vpc-id,Values=${vpcId}" --query "Subnets[0].SubnetId" --output text --region ${region} ${profileFlag}`, { encoding: "utf-8" }).trim();
1183
+ // Reuse the security group when it already exists (normal on redeploys);
1184
+ // create it only when missing.
1185
+ const existingSg = execSync(`aws ec2 describe-security-groups --filters "Name=group-name,Values=citio-sg" "Name=vpc-id,Values=${vpcId}" --query "SecurityGroups[0].GroupId" --output text --region ${region} ${profileFlag}`, { encoding: "utf-8", stdio: "pipe" }).trim();
1186
+ if (existingSg && existingSg !== "None") {
1187
+ sgId = existingSg;
1188
+ }
1189
+ else {
1190
+ sgId = execSync(`aws ec2 create-security-group --group-name citio-sg --description "Citio agent - outbound only" --vpc-id ${vpcId} --region ${region} ${profileFlag} --output text --query GroupId`, { encoding: "utf-8", stdio: "pipe" }).trim();
1191
+ }
1192
+ }
1193
+ catch (err) {
1194
+ s.stop("Failed to configure networking. Using defaults.");
1195
+ p.log.error(`Networking error: ${err instanceof Error ? err.message : String(err)}`);
1196
+ process.exit(1);
1197
+ }
1198
+ s.stop("Networking configured");
1199
+ // 8b. EFS is unreachable without a mount target in the task's subnet — every
1200
+ // task dies with ResourceInitializationError ("Failed to resolve fs-....efs...").
1201
+ if (efsFileSystemId) {
1202
+ s.start("Ensuring EFS mount target in the task subnet...");
1203
+ try {
1204
+ execSync(`aws ec2 authorize-security-group-ingress --group-id ${sgId} --protocol tcp --port 2049 --source-group ${sgId} --region ${region} ${profileFlag}`, { encoding: "utf-8", stdio: "pipe" });
1205
+ }
1206
+ catch {
1207
+ // Duplicate rule — already allowed.
1208
+ }
1209
+ const existingState = runDeployCommand(`aws efs describe-mount-targets --file-system-id ${efsFileSystemId} --region ${region} ${profileFlag} --query "MountTargets[?SubnetId=='${subnetId}'].LifeCycleState" --output text`, "Failed to inspect EFS mount targets.").trim();
1210
+ if (!existingState || existingState === "None") {
1211
+ runDeployCommand(`aws efs create-mount-target --file-system-id ${efsFileSystemId} --subnet-id ${subnetId} --security-groups ${sgId} --region ${region} ${profileFlag}`, "Failed to create the EFS mount target.");
1212
+ }
1213
+ // Tasks launched before the mount target is 'available' still fail — wait for it.
1214
+ let mountTargetReady = existingState === "available";
1215
+ for (let attempt = 0; attempt < 30 && !mountTargetReady; attempt++) {
1216
+ sleep(10);
1217
+ const state = runDeployCommand(`aws efs describe-mount-targets --file-system-id ${efsFileSystemId} --region ${region} ${profileFlag} --query "MountTargets[?SubnetId=='${subnetId}'].LifeCycleState" --output text`, "Failed to poll the EFS mount target.").trim();
1218
+ mountTargetReady = state === "available";
1219
+ }
1220
+ if (!mountTargetReady) {
1221
+ p.log.error("EFS mount target did not become available within 5 minutes. Re-run the installer once it is (aws efs describe-mount-targets).");
1222
+ process.exit(1);
1223
+ }
1224
+ s.stop("EFS mount target available");
1225
+ // Prep the EFS home dir (ownership + directories) before anything mounts it.
1226
+ s.start("Preparing EFS home directory (permissions)...");
1227
+ const initTask = startCodexAuthSetupTask({
1228
+ accountId,
1229
+ ecrUri,
1230
+ region,
1231
+ profileFlag,
1232
+ efsFileSystemId,
1233
+ subnetId,
1234
+ securityGroupId: sgId,
1235
+ mode: "efs_init",
1236
+ });
1237
+ const initResult = waitForTaskStop(initTask.taskArn, region, profileFlag, 18, 10);
1238
+ if (initResult.exitCode === "0") {
1239
+ s.stop("EFS home directory ready.");
1240
+ }
1241
+ else {
1242
+ s.stop("EFS home prep did not confirm — the service may crash-loop on permissions; check the auth-setup log stream.");
1243
+ }
1244
+ }
1245
+ // 9. Create/update ECS service
1246
+ s.start("Deploying ECS service...");
1247
+ try {
1248
+ runDeployCommand(`aws ecs create-service \
1249
+ --cluster citio \
1250
+ --service-name citio \
1251
+ --task-definition citio \
1252
+ --desired-count 1 \
1253
+ --launch-type FARGATE \
1254
+ --availability-zone-rebalancing DISABLED \
1255
+ --deployment-configuration "maximumPercent=100,minimumHealthyPercent=0" \
1256
+ --network-configuration "awsvpcConfiguration={subnets=[${subnetId}],securityGroups=[${sgId}],assignPublicIp=ENABLED}" \
1257
+ --region ${region} ${profileFlag}`, "Failed to create the ECS service.");
1258
+ }
1259
+ catch {
1260
+ // Service may already exist, update it
1261
+ runDeployCommand(`aws ecs update-service \
1262
+ --cluster citio \
1263
+ --service citio \
1264
+ --task-definition citio \
1265
+ --force-new-deployment \
1266
+ --region ${region} ${profileFlag}`, "Failed to update the ECS service.");
1267
+ }
1268
+ s.stop("ECS service deployed!");
1269
+ p.log.success("ECS service deployed!");
1270
+ // Post-deploy: the container authenticates ITSELF via device auth, so it owns
1271
+ // its own OAuth token family. (Uploading the laptop's auth.json was removed —
1272
+ // single-use refresh tokens made the two machines race and corrupt the family.)
1273
+ if (config.authMethod === "oauth" && config.provider === "codex") {
1274
+ try {
1275
+ const { subnetId, securityGroupId } = getServiceNetworkConfig(region, profileFlag);
1276
+ let authBootstrapped = false;
1277
+ {
1278
+ s.start("Starting Codex device auth task...");
1279
+ const authTask = startCodexAuthSetupTask({
1280
+ accountId,
1281
+ ecrUri,
1282
+ region,
1283
+ profileFlag,
1284
+ efsFileSystemId,
1285
+ subnetId,
1286
+ securityGroupId,
1287
+ mode: "device_auth",
1288
+ });
1289
+ s.stop("Codex device auth task started.");
1290
+ p.log.info("Complete the OpenAI device-auth flow shown in the task logs below.\n" +
1291
+ "Once the login finishes, the saved auth on EFS will be reused and refreshed by Codex on future boots.");
1292
+ const seenMessages = new Set();
1293
+ let exitCode = "";
1294
+ for (let i = 0; i < 36; i++) {
1295
+ printNewCloudWatchLines("/ecs/citio", authTask.logStream, region, profileFlag, seenMessages);
1296
+ const result = waitForTaskStop(authTask.taskArn, region, profileFlag, 1, 10);
1297
+ if (result.exitCode) {
1298
+ exitCode = result.exitCode;
1299
+ break;
1300
+ }
1301
+ }
1302
+ if (exitCode === "0") {
1303
+ p.log.success("Codex device auth completed and was saved to EFS.");
1304
+ authBootstrapped = true;
1305
+ }
1306
+ else {
1307
+ p.log.warn("Codex device auth did not finish successfully. Re-run the installer or use OPENAI_API_KEY as a fallback.");
1308
+ }
1309
+ }
1310
+ if (authBootstrapped) {
1311
+ execSync(`aws ecs update-service --cluster citio --service citio --force-new-deployment --region ${region} ${profileFlag}`, { stdio: "pipe" });
1312
+ }
1313
+ }
1314
+ catch (err) {
1315
+ p.log.warn(`Codex OAuth bootstrap failed: ${err instanceof Error ? err.message : String(err)}`);
1316
+ }
1317
+ }
1318
+ // Post-deploy verification
1319
+ const verifyS = p.spinner();
1320
+ verifyS.start("Waiting for ECS service to stabilize...");
1321
+ let serviceHealthy = false;
1322
+ const maxRetries = 12; // 12 x 15s = 3 minutes
1323
+ for (let i = 0; i < maxRetries; i++) {
1324
+ try {
1325
+ const serviceJson = execSync(`aws ecs describe-services --cluster citio --services citio --region ${region} ${profileFlag} --query 'services[0].{status:status,running:runningCount,desired:desiredCount,events:events[0].message}' --output json`, { encoding: "utf-8", stdio: "pipe" });
1326
+ const svc = JSON.parse(serviceJson);
1327
+ if (svc.running >= svc.desired && svc.running > 0) {
1328
+ serviceHealthy = true;
1329
+ break;
1330
+ }
1331
+ // Show what's happening
1332
+ verifyS.message(`Task status: ${svc.running}/${svc.desired} running. ${svc.events || "Starting..."}`);
1333
+ }
1334
+ catch {
1335
+ // Service might not be queryable yet
1336
+ }
1337
+ // Wait 15 seconds before checking again
1338
+ execSync("sleep 15", { stdio: "pipe" });
1339
+ }
1340
+ if (serviceHealthy) {
1341
+ verifyS.stop("ECS service is running!");
1342
+ }
1343
+ else {
1344
+ verifyS.stop("ECS service not yet healthy.");
1345
+ // Check for errors in logs
1346
+ p.log.warn("The service may still be starting. Checking logs for errors...");
1347
+ try {
1348
+ const logs = execSync(`aws logs filter-log-events --log-group-name /ecs/citio --region ${region} ${profileFlag} --limit 10 --query 'events[].message' --output text 2>/dev/null`, { encoding: "utf-8", stdio: "pipe", timeout: 15000 });
1349
+ if (logs.trim()) {
1350
+ p.log.info("Recent logs:\n" + logs.trim());
1351
+ }
1352
+ }
1353
+ catch {
1354
+ p.log.info("No logs available yet (log group may not exist until the task runs).");
1355
+ }
1356
+ // Check the task's stopped reason
1357
+ try {
1358
+ const stoppedReason = execSync(`aws ecs describe-tasks --cluster citio --tasks $(aws ecs list-tasks --cluster citio --service-name citio --desired-status STOPPED --region ${region} ${profileFlag} --query 'taskArns[0]' --output text 2>/dev/null) --region ${region} ${profileFlag} --query 'tasks[0].stoppedReason' --output text 2>/dev/null`, { encoding: "utf-8", stdio: "pipe", timeout: 15000 }).trim();
1359
+ if (stoppedReason && stoppedReason !== "None") {
1360
+ p.log.error(`Task stopped: ${stoppedReason}`);
1361
+ }
1362
+ }
1363
+ catch {
1364
+ // No stopped tasks to inspect
1365
+ }
1366
+ }
1367
+ // Final status
1368
+ p.log.success(serviceHealthy ? "\nCitio is live!" : "\nDeployment started.");
1369
+ p.log.info(`Monitor: aws ecs describe-services --cluster citio --services citio --region ${region} ${profileFlag}` +
1370
+ `\nLogs: aws logs tail /ecs/citio --region ${region} ${profileFlag} --follow` +
1371
+ `\nHealth: Check the task's public IP on port 3001/healthz`);
1372
+ return serviceHealthy;
1373
+ }
1374
+ async function main() {
1375
+ p.intro(pc.bgCyan(pc.black(" Citio ")) + " " + pc.bold("Autonomous CTO Agent") + pc.dim(" — self-hosted, in your Slack"));
1376
+ p.note("Setup takes ~10 minutes and walks you through every credential.\n" +
1377
+ "Have these ready (each step shows exactly where to get them):\n" +
1378
+ "\n" +
1379
+ ` ${ok("✓")} A Slack workspace where you can create an app\n` +
1380
+ ` ${ok("✓")} A GitHub account (we'll create a token together)\n` +
1381
+ ` ${ok("✓")} An AWS account + CLI (no CLI or unsure about permissions?\n` +
1382
+ " see docs/AWS_SETUP.md — we also verify before deploying)\n" +
1383
+ ` ${ok("✓")} A Claude Max/Pro or ChatGPT Go/Plus/Pro login for the agent\n` +
1384
+ "\n" +
1385
+ `Your answers are saved as you go — if anything fails, re-run\n` +
1386
+ `${cmd("npx citio")} and it resumes with your saved values.`, "Before you start");
1387
+ checkPrerequisites();
1388
+ const config = await collectConfig();
1389
+ const s = p.spinner();
1390
+ // Write config files
1391
+ s.start("Writing configuration...");
1392
+ writeConfigFile(config);
1393
+ const secretBackend = await saveInstallerState({
1394
+ provider: config.provider,
1395
+ authMethod: config.authMethod,
1396
+ slackChannelId: config.slackChannelId,
1397
+ repos: config.repos,
1398
+ rules: config.rules,
1399
+ skills: config.skills,
1400
+ gitUserEmail: config.gitUserEmail || undefined,
1401
+ awsRegion: config.awsRegion,
1402
+ awsProfile: config.awsProfile,
1403
+ enableEfs: config.enableEfs,
1404
+ }, {
1405
+ slackBotToken: config.slackBotToken,
1406
+ slackAppToken: config.slackAppToken,
1407
+ githubToken: config.githubToken,
1408
+ claudeOauthToken: config.claudeOauthToken || undefined,
1409
+ openAiApiKey: config.provider === "codex" && config.authMethod === "api_key" ? config.providerApiKey : undefined,
1410
+ anthropicApiKey: config.provider === "claude" && config.authMethod === "api_key" ? config.providerApiKey : undefined,
1411
+ });
1412
+ s.stop(`Configuration saved to citio.yaml. Secrets saved to ${secretBackend}.`);
1413
+ // Install skills
1414
+ if (config.skills.length > 0) {
1415
+ installSkills(config.skills, config.githubToken);
1416
+ }
1417
+ // Deploy
1418
+ const shouldDeploy = (await p.confirm({
1419
+ message: "Deploy to AWS now?",
1420
+ initialValue: true,
1421
+ }));
1422
+ if (p.isCancel(shouldDeploy))
1423
+ process.exit(0);
1424
+ if (shouldDeploy) {
1425
+ const healthy = await deployToAws(config);
1426
+ if (healthy) {
1427
+ p.outro("Citio is ready! Send a message in your Slack channel to test.");
1428
+ }
1429
+ else {
1430
+ p.outro("Deployment started, but the service is NOT confirmed healthy yet.\n" +
1431
+ " Watch the logs above; when the task shows RUNNING, test it in Slack.\n" +
1432
+ " If it keeps failing, fix the reported error and re-run the installer — your answers are saved.");
1433
+ }
1434
+ }
1435
+ else {
1436
+ p.log.info("Skipping deploy. Secrets are stored outside the repo now, so local docker runs need explicit env vars or provider auth mounts.");
1437
+ p.outro("Configuration saved. Re-run and choose deploy when you're ready.");
1438
+ }
1439
+ }
1440
+ // Subcommand dispatch: `citio` = install wizard · `citio status` · `citio destroy`
1441
+ const subcommand = process.argv[2];
1442
+ const entry = subcommand === "status" ? statusCommand :
1443
+ subcommand === "destroy" ? destroyCommand :
1444
+ subcommand === "manifest" ? async () => {
1445
+ const { writeFileSync } = await import("fs");
1446
+ const json = JSON.stringify(buildCitioSlackManifest(), null, 2);
1447
+ writeFileSync("slack-app-manifest.json", json + "\n");
1448
+ try {
1449
+ execSync("pbcopy", { input: json });
1450
+ }
1451
+ catch { /* non-mac: file only */ }
1452
+ console.log(json);
1453
+ console.error("\nSaved to slack-app-manifest.json (and copied to clipboard on macOS).\nPaste into api.slack.com/apps -> your app -> App Manifest -> Save.");
1454
+ } :
1455
+ subcommand === undefined ? main :
1456
+ async () => {
1457
+ console.error(`Unknown command "${subcommand}". Usage: citio [status|destroy]`);
1458
+ process.exit(1);
1459
+ };
1460
+ entry().catch((err) => {
1461
+ console.error("Error:", err);
1462
+ process.exit(1);
1463
+ });
1464
+ //# sourceMappingURL=init.js.map