@athenode/cli 0.0.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/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # @athenode/cli
2
+
3
+ The official command-line interface for Athenode — manage your project's specifications,
4
+ track their status, and record implementation results, all without leaving your terminal.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ npm install -g @athenode/cli
10
+ ```
11
+
12
+ ## Getting started
13
+
14
+ Run the interactive setup once in your project:
15
+
16
+ ```bash
17
+ athenode init
18
+ ```
19
+
20
+ This walks you through connecting the CLI to your Athenode project and stores a local,
21
+ private config file so you don't have to re-authenticate every time.
22
+
23
+ Already have a project token? Add it directly:
24
+
25
+ ```bash
26
+ athenode auth add-token <token>
27
+ ```
28
+
29
+ ## What you can do
30
+
31
+ - **Browse and search specifications** — list, filter, and full-text search the
32
+ specifications in your project.
33
+ - **Create and edit specifications** — draft new specifications and update their title,
34
+ summary, content, or status right from the command line.
35
+ - **Track implementation plans** — attach or read back the implementation plan for any
36
+ specification.
37
+ - **Record execution results** — log what changed when a specification is implemented.
38
+ - **Manage clarifying questions** — add and answer the questions attached to a
39
+ specification during planning.
40
+
41
+ Run `athenode --help` or `athenode <command> --help` at any time to see everything
42
+ available. Every command also supports a `--json` flag if you'd rather pipe the output
43
+ into another tool.
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NoActiveAgentSetupError = exports.InvalidTokenError = exports.MissingTokenError = void 0;
4
+ exports.getApiBaseUrl = getApiBaseUrl;
5
+ exports.getStoredToken = getStoredToken;
6
+ exports.getStoredProjectId = getStoredProjectId;
7
+ exports.resolveProjectIdFromToken = resolveProjectIdFromToken;
8
+ exports.listAgentSetups = listAgentSetups;
9
+ exports.getAgentSetupDetail = getAgentSetupDetail;
10
+ exports.getActiveAgentSetupDetail = getActiveAgentSetupDetail;
11
+ exports.apiRequest = apiRequest;
12
+ const config_1 = require("./config");
13
+ const env_1 = require("./env");
14
+ /**
15
+ * Resolves the backend origin from the `ATHENODE_API_URL` env var, falling back to a
16
+ * sensible localhost default. This is a bare origin (no trailing `/api`); callers append
17
+ * their own `/api/...` paths.
18
+ */
19
+ function getApiBaseUrl() {
20
+ const configured = process.env['ATHENODE_API_URL'];
21
+ const base = configured && configured.trim() ? configured.trim() : env_1.DEFAULT_API_URL;
22
+ return base.endsWith('/') ? base.slice(0, -1) : base;
23
+ }
24
+ /** Reads the stored project token from the project-local config, if any. */
25
+ function getStoredToken() {
26
+ return (0, config_1.readConfig)().token;
27
+ }
28
+ /** Reads the stored project id from the project-local config, if any. */
29
+ function getStoredProjectId() {
30
+ return (0, config_1.readConfig)().projectId;
31
+ }
32
+ /**
33
+ * Thrown when a request is made without a stored token. Callers should catch this and
34
+ * instruct the user to run `athenode auth add-token <token>`.
35
+ */
36
+ class MissingTokenError extends Error {
37
+ constructor() {
38
+ super('No project token found. Run `athenode auth add-token <token>` first.');
39
+ this.name = 'MissingTokenError';
40
+ }
41
+ }
42
+ exports.MissingTokenError = MissingTokenError;
43
+ /**
44
+ * Thrown when a token fails to resolve to a project id (invalid/revoked token, or a
45
+ * non-2xx/malformed response from `GET /api/auth/me`).
46
+ */
47
+ class InvalidTokenError extends Error {
48
+ constructor(message) {
49
+ super(message);
50
+ this.name = 'InvalidTokenError';
51
+ }
52
+ }
53
+ exports.InvalidTokenError = InvalidTokenError;
54
+ /**
55
+ * Calls `GET /api/auth/me` with `token` directly (rather than via {@link apiRequest}, which
56
+ * reads the token from config — not yet written at the point this is called from `auth
57
+ * add-token`), validating the token and resolving the single project it is scoped to.
58
+ * Throws {@link InvalidTokenError} on any non-2xx response or unexpected body shape.
59
+ */
60
+ async function resolveProjectIdFromToken(token) {
61
+ const res = await fetch(`${getApiBaseUrl()}/api/auth/me`, {
62
+ headers: { Authorization: `Bearer ${token}` },
63
+ });
64
+ const text = await res.text();
65
+ let parsed;
66
+ try {
67
+ parsed = text.length ? JSON.parse(text) : undefined;
68
+ }
69
+ catch {
70
+ parsed = undefined;
71
+ }
72
+ if (res.status < 200 || res.status >= 300) {
73
+ const message = parsed && typeof parsed === 'object' && 'error' in parsed && typeof parsed.error === 'string'
74
+ ? parsed.error
75
+ : `request failed with status ${res.status}`;
76
+ throw new InvalidTokenError(message);
77
+ }
78
+ const projectId = parsed && typeof parsed === 'object' ? parsed.projectId : undefined;
79
+ if (typeof projectId !== 'string' || !projectId) {
80
+ throw new InvalidTokenError('unexpected response from the server while resolving the project id');
81
+ }
82
+ return projectId;
83
+ }
84
+ /** Lists the agent setups belonging to `projectId` (no content, just enough to find the active one). */
85
+ async function listAgentSetups(projectId) {
86
+ return apiRequest(`/api/projects/${projectId}/agent-setups`);
87
+ }
88
+ /** Fetches the full detail (agents + commands) of a single agent setup by id. */
89
+ async function getAgentSetupDetail(id) {
90
+ return apiRequest(`/api/agent-setups/${id}`);
91
+ }
92
+ /**
93
+ * Thrown when a project has no active agent setup (defensive — every project should have a
94
+ * default setup that is active, but the backend has no dedicated "get my active setup"
95
+ * endpoint, so this composes list + detail and surfaces the absence explicitly).
96
+ */
97
+ class NoActiveAgentSetupError extends Error {
98
+ constructor(projectId) {
99
+ super(`Project ${projectId} has no active agent setup.`);
100
+ this.name = 'NoActiveAgentSetupError';
101
+ }
102
+ }
103
+ exports.NoActiveAgentSetupError = NoActiveAgentSetupError;
104
+ /**
105
+ * Resolves the project's active agent setup in full detail, composing the two-hop
106
+ * list-by-project → filter(isActive) → get-detail-by-id calls, since there is no single
107
+ * "get my active setup" endpoint. Throws {@link NoActiveAgentSetupError} if the list request
108
+ * fails or no setup is marked active, and propagates any other non-2xx response as-is via the
109
+ * returned {@link ApiResponse}.
110
+ */
111
+ async function getActiveAgentSetupDetail(projectId) {
112
+ const listRes = await listAgentSetups(projectId);
113
+ if (listRes.status < 200 || listRes.status >= 300) {
114
+ return { status: listRes.status, body: listRes.body };
115
+ }
116
+ const active = listRes.body.find((setup) => setup.isActive);
117
+ if (!active) {
118
+ throw new NoActiveAgentSetupError(projectId);
119
+ }
120
+ return getAgentSetupDetail(active.id);
121
+ }
122
+ async function apiRequest(path, options = {}) {
123
+ const token = getStoredToken();
124
+ if (!token) {
125
+ throw new MissingTokenError();
126
+ }
127
+ const { method = 'GET', body, headers = {} } = options;
128
+ const url = `${getApiBaseUrl()}${path}`;
129
+ const res = await fetch(url, {
130
+ method,
131
+ headers: {
132
+ Authorization: `Bearer ${token}`,
133
+ ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
134
+ ...headers,
135
+ },
136
+ body: body !== undefined ? JSON.stringify(body) : undefined,
137
+ });
138
+ const text = await res.text();
139
+ let parsed;
140
+ try {
141
+ parsed = text.length ? JSON.parse(text) : undefined;
142
+ }
143
+ catch {
144
+ parsed = text;
145
+ }
146
+ return { status: res.status, body: parsed };
147
+ }
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.addTokenCommand = addTokenCommand;
4
+ const apiClient_1 = require("../apiClient");
5
+ const config_1 = require("../config");
6
+ /**
7
+ * Implements `athenode auth add-token <token>`: validates an already-minted project
8
+ * token against the backend (`GET /api/auth/me`), which also resolves the single project
9
+ * id it is scoped to, then stores both into the project-local config file. Lazily
10
+ * initializes the config (and `.gitignore` entry) if `init` has not been run yet, so this
11
+ * command works standalone.
12
+ */
13
+ async function addTokenCommand(token) {
14
+ const trimmed = token.trim();
15
+ if (!trimmed) {
16
+ console.error('Error: token must not be empty.');
17
+ process.exitCode = 1;
18
+ return;
19
+ }
20
+ let projectId;
21
+ try {
22
+ projectId = await (0, apiClient_1.resolveProjectIdFromToken)(trimmed);
23
+ }
24
+ catch (err) {
25
+ const message = err instanceof apiClient_1.InvalidTokenError ? err.message : err instanceof Error ? err.message : String(err);
26
+ console.error(`Error: invalid token (${message})`);
27
+ process.exitCode = 1;
28
+ return;
29
+ }
30
+ const wasInitialized = (0, config_1.configExists)();
31
+ (0, config_1.updateConfig)({ token: trimmed, projectId });
32
+ (0, config_1.ensureGitignoreEntry)();
33
+ if (!wasInitialized) {
34
+ console.log('Initialized .athenode/config.json');
35
+ }
36
+ console.log('Token stored in .athenode/config.json');
37
+ console.log(`Project id: ${projectId} (resolved from token)`);
38
+ }
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.executionResultsListCommand = executionResultsListCommand;
4
+ exports.executionResultsCreateCommand = executionResultsCreateCommand;
5
+ const apiClient_1 = require("../apiClient");
6
+ const errors_1 = require("../errors");
7
+ const output_1 = require("../output");
8
+ async function executionResultsListCommand(specificationId, options) {
9
+ await (0, errors_1.withErrorHandling)(async () => {
10
+ const res = await (0, apiClient_1.apiRequest)(`/api/specifications/${specificationId}/execution-results`);
11
+ if ((0, errors_1.handleApiError)(res)) {
12
+ return;
13
+ }
14
+ if (options.json) {
15
+ (0, output_1.printJson)(res.body);
16
+ }
17
+ else {
18
+ console.log((0, output_1.formatExecutionResultList)(res.body));
19
+ }
20
+ });
21
+ }
22
+ async function executionResultsCreateCommand(specificationId, options) {
23
+ await (0, errors_1.withErrorHandling)(async () => {
24
+ const res = await (0, apiClient_1.apiRequest)(`/api/specifications/${specificationId}/execution-results`, {
25
+ method: 'POST',
26
+ body: { filePath: options.filePath, summary: options.summary },
27
+ });
28
+ if ((0, errors_1.handleApiError)(res)) {
29
+ return;
30
+ }
31
+ if (options.json) {
32
+ (0, output_1.printJson)(res.body);
33
+ }
34
+ else {
35
+ console.log((0, output_1.formatExecutionResult)(res.body));
36
+ }
37
+ });
38
+ }
@@ -0,0 +1,194 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.initCommand = initCommand;
4
+ const tslib_1 = require("tslib");
5
+ const fs = tslib_1.__importStar(require("fs"));
6
+ const apiClient_1 = require("../apiClient");
7
+ const config_1 = require("../config");
8
+ const errors_1 = require("../errors");
9
+ const fileInstall_1 = require("../fileInstall");
10
+ let clackPromise;
11
+ function clack() {
12
+ if (!clackPromise) {
13
+ clackPromise = import('@clack/prompts');
14
+ }
15
+ return clackPromise;
16
+ }
17
+ /**
18
+ * Implements `athenode init`: an interactive flow that (1) imports/validates a project
19
+ * token if one isn't already stored, (2) fetches the project's active agent setup, (3) lets
20
+ * the user pick which agents to install, and (4) writes the resulting command/agent
21
+ * files into `.claude/commands/` and `.claude/agents/`, prompting on conflicts with files
22
+ * that already exist on disk.
23
+ *
24
+ * Safe/idempotent to re-run: an already-stored token is reused rather than re-prompted for,
25
+ * and files whose on-disk content already matches are left untouched without prompting.
26
+ */
27
+ async function initCommand() {
28
+ const p = await clack();
29
+ const projectId = await ensureProjectToken(p);
30
+ if (!projectId) {
31
+ return;
32
+ }
33
+ (0, config_1.ensureGitignoreEntry)();
34
+ let setup;
35
+ try {
36
+ const res = await (0, apiClient_1.getActiveAgentSetupDetail)(projectId);
37
+ if ((0, errors_1.handleApiError)(res)) {
38
+ return;
39
+ }
40
+ setup = res.body;
41
+ }
42
+ catch (err) {
43
+ if (err instanceof apiClient_1.NoActiveAgentSetupError) {
44
+ console.error(`Error: ${err.message}`);
45
+ process.exitCode = 1;
46
+ return;
47
+ }
48
+ throw err;
49
+ }
50
+ let selectedAgents = setup.agents;
51
+ if (setup.agents.length > 0) {
52
+ const agentNames = await p.multiselect({
53
+ message: `Select agents to install from "${setup.name}"`,
54
+ options: setup.agents.map((a) => ({ value: a.name, label: a.name })),
55
+ initialValues: setup.agents.map((a) => a.name),
56
+ required: false,
57
+ });
58
+ if (p.isCancel(agentNames)) {
59
+ p.cancel('Aborted: no selection made.');
60
+ process.exitCode = 1;
61
+ return;
62
+ }
63
+ const selectedNames = new Set(agentNames);
64
+ selectedAgents = setup.agents.filter((a) => selectedNames.has(a.name));
65
+ }
66
+ const plan = [
67
+ ...(0, fileInstall_1.planInstall)(setup.commands, '.claude/commands'),
68
+ ...(0, fileInstall_1.planInstall)(selectedAgents, '.claude/agents'),
69
+ ];
70
+ let written = 0;
71
+ let skipped = 0;
72
+ let unchanged = 0;
73
+ for (const entry of plan) {
74
+ if (!entry.conflict) {
75
+ if (!entry.exists) {
76
+ (0, fileInstall_1.writeInstallEntry)(entry, 'overwrite');
77
+ written += 1;
78
+ }
79
+ else {
80
+ unchanged += 1;
81
+ }
82
+ continue;
83
+ }
84
+ const resolution = await resolveConflict(p, entry);
85
+ if (resolution === 'overwrite') {
86
+ (0, fileInstall_1.writeInstallEntry)(entry, 'overwrite');
87
+ written += 1;
88
+ }
89
+ else {
90
+ skipped += 1;
91
+ }
92
+ }
93
+ console.log('');
94
+ console.log(`Installed ${written} file(s), skipped ${skipped} conflicting file(s), ${unchanged} already up to date.`);
95
+ }
96
+ /**
97
+ * Ensures a project token is stored, prompting for one interactively if not, and returns the
98
+ * resolved project id. Returns `undefined` (with `process.exitCode` set) on failure.
99
+ */
100
+ async function ensureProjectToken(p) {
101
+ const existingToken = (0, apiClient_1.getStoredToken)();
102
+ const existingProjectId = (0, apiClient_1.getStoredProjectId)();
103
+ if (existingToken && existingProjectId) {
104
+ console.log('Using stored project token (.athenode/config.json).');
105
+ return existingProjectId;
106
+ }
107
+ const wasInitialized = (0, config_1.configExists)();
108
+ const token = await p.password({
109
+ message: 'Enter your athenode project token',
110
+ });
111
+ if (p.isCancel(token)) {
112
+ p.cancel('Aborted.');
113
+ process.exitCode = 1;
114
+ return undefined;
115
+ }
116
+ const trimmed = token.trim();
117
+ if (!trimmed) {
118
+ console.error('Error: token must not be empty.');
119
+ process.exitCode = 1;
120
+ return undefined;
121
+ }
122
+ let projectId;
123
+ try {
124
+ projectId = await (0, apiClient_1.resolveProjectIdFromToken)(trimmed);
125
+ }
126
+ catch (err) {
127
+ const message = err instanceof apiClient_1.InvalidTokenError ? err.message : err instanceof Error ? err.message : String(err);
128
+ console.error(`Error: invalid token (${message})`);
129
+ process.exitCode = 1;
130
+ return undefined;
131
+ }
132
+ (0, config_1.updateConfig)({ token: trimmed, projectId });
133
+ if (!wasInitialized) {
134
+ console.log('Initialized .athenode/config.json');
135
+ }
136
+ console.log(`Token stored. Project id: ${projectId} (resolved from token)`);
137
+ return projectId;
138
+ }
139
+ /**
140
+ * Renders a minimal line-by-line diff between the on-disk file and the incoming content.
141
+ * Deliberately simple (no LCS/word-level alignment, no external diff dependency): lines that
142
+ * differ position-by-position are shown as removed/added pairs, and a trailing length
143
+ * mismatch is reported as pure additions or removals.
144
+ */
145
+ function renderDiff(existingContent, incomingContent) {
146
+ const existingLines = existingContent.split('\n');
147
+ const incomingLines = incomingContent.split('\n');
148
+ const maxLines = Math.max(existingLines.length, incomingLines.length);
149
+ const out = [];
150
+ for (let i = 0; i < maxLines; i += 1) {
151
+ const existingLine = existingLines[i];
152
+ const incomingLine = incomingLines[i];
153
+ if (existingLine === incomingLine) {
154
+ continue;
155
+ }
156
+ if (existingLine !== undefined) {
157
+ out.push(`- ${existingLine}`);
158
+ }
159
+ if (incomingLine !== undefined) {
160
+ out.push(`+ ${incomingLine}`);
161
+ }
162
+ }
163
+ return out.length > 0 ? out.join('\n') : '(no line-level differences found)';
164
+ }
165
+ /**
166
+ * Prompts the user how to resolve a single conflicting install entry: skip, overwrite, or show
167
+ * a diff first (which re-prompts the same question afterward, since "show diff" isn't itself
168
+ * a resolution).
169
+ */
170
+ async function resolveConflict(p, entry) {
171
+ for (;;) {
172
+ const resolution = await p.select({
173
+ message: `${entry.targetPath} already exists with different content. What would you like to do?`,
174
+ options: [
175
+ { value: 'overwrite', label: 'Overwrite' },
176
+ { value: 'skip', label: 'Skip' },
177
+ { value: 'diff', label: 'Show diff' },
178
+ ],
179
+ initialValue: 'skip',
180
+ });
181
+ if (p.isCancel(resolution)) {
182
+ p.cancel('Aborted.');
183
+ return 'skip';
184
+ }
185
+ if (resolution === 'diff') {
186
+ const existingContent = fs.readFileSync(entry.targetPath, 'utf8');
187
+ console.log('');
188
+ console.log(renderDiff(existingContent, entry.item.content));
189
+ console.log('');
190
+ continue;
191
+ }
192
+ return resolution;
193
+ }
194
+ }
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.questionsListCommand = questionsListCommand;
4
+ exports.questionsAddCommand = questionsAddCommand;
5
+ exports.questionsAnswerCommand = questionsAnswerCommand;
6
+ const apiClient_1 = require("../apiClient");
7
+ const errors_1 = require("../errors");
8
+ const output_1 = require("../output");
9
+ async function questionsListCommand(specificationId, options) {
10
+ await (0, errors_1.withErrorHandling)(async () => {
11
+ const res = await (0, apiClient_1.apiRequest)(`/api/specifications/${specificationId}/questions`);
12
+ if ((0, errors_1.handleApiError)(res)) {
13
+ return;
14
+ }
15
+ if (options.json) {
16
+ (0, output_1.printJson)(res.body);
17
+ }
18
+ else {
19
+ console.log((0, output_1.formatQuestionList)(res.body));
20
+ }
21
+ });
22
+ }
23
+ async function questionsAddCommand(specificationId, options) {
24
+ await (0, errors_1.withErrorHandling)(async () => {
25
+ const res = await (0, apiClient_1.apiRequest)(`/api/specifications/${specificationId}/questions`, { method: 'POST', body: { question: options.question } });
26
+ if ((0, errors_1.handleApiError)(res)) {
27
+ return;
28
+ }
29
+ if (options.json) {
30
+ (0, output_1.printJson)(res.body);
31
+ }
32
+ else {
33
+ console.log((0, output_1.formatQuestion)(res.body));
34
+ }
35
+ });
36
+ }
37
+ async function questionsAnswerCommand(specificationId, questionId, options) {
38
+ await (0, errors_1.withErrorHandling)(async () => {
39
+ const res = await (0, apiClient_1.apiRequest)(`/api/specifications/${specificationId}/questions/${questionId}`, { method: 'PATCH', body: { answer: options.answer } });
40
+ if ((0, errors_1.handleApiError)(res)) {
41
+ return;
42
+ }
43
+ if (options.json) {
44
+ (0, output_1.printJson)(res.body);
45
+ }
46
+ else {
47
+ console.log((0, output_1.formatQuestion)(res.body));
48
+ }
49
+ });
50
+ }
@@ -0,0 +1,231 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.specsListCommand = specsListCommand;
4
+ exports.specsSearchCommand = specsSearchCommand;
5
+ exports.specsGetCommand = specsGetCommand;
6
+ exports.specsCreateCommand = specsCreateCommand;
7
+ exports.specsUpdateCommand = specsUpdateCommand;
8
+ exports.specsSetStatusCommand = specsSetStatusCommand;
9
+ exports.specsSetPlanCommand = specsSetPlanCommand;
10
+ exports.specsGetPlanCommand = specsGetPlanCommand;
11
+ const apiClient_1 = require("../apiClient");
12
+ const errors_1 = require("../errors");
13
+ const output_1 = require("../output");
14
+ /** Resolves the project id to use for `list`/`search`: the `--project` flag, or the value stored by `auth add-token <token>`. */
15
+ function resolveProjectId(projectOption) {
16
+ return projectOption ?? (0, apiClient_1.getStoredProjectId)();
17
+ }
18
+ async function specsListCommand(options) {
19
+ await (0, errors_1.withErrorHandling)(async () => {
20
+ const projectId = resolveProjectId(options.project);
21
+ if (!projectId) {
22
+ console.error('Error: no project id available. Pass --project <projectId> or run `athenode auth add-token <token>`.');
23
+ process.exitCode = 1;
24
+ return;
25
+ }
26
+ if (options.topLevel && options.parent !== undefined) {
27
+ console.error('Error: --parent and --top-level are mutually exclusive.');
28
+ process.exitCode = 1;
29
+ return;
30
+ }
31
+ const query = new URLSearchParams();
32
+ if (options.status !== undefined) {
33
+ query.set('status', options.status);
34
+ }
35
+ if (options.parent !== undefined) {
36
+ query.set('parentId', options.parent);
37
+ }
38
+ if (options.topLevel) {
39
+ query.set('topLevel', 'true');
40
+ }
41
+ const queryString = query.toString();
42
+ const res = await (0, apiClient_1.apiRequest)(`/api/projects/${projectId}/specifications${queryString ? `?${queryString}` : ''}`);
43
+ if ((0, errors_1.handleApiError)(res)) {
44
+ return;
45
+ }
46
+ if (options.json) {
47
+ (0, output_1.printJson)(res.body);
48
+ }
49
+ else {
50
+ console.log((0, output_1.formatSpecificationList)(res.body));
51
+ }
52
+ });
53
+ }
54
+ async function specsSearchCommand(query, options) {
55
+ await (0, errors_1.withErrorHandling)(async () => {
56
+ const projectId = resolveProjectId(options.project);
57
+ if (!projectId) {
58
+ console.error('Error: no project id available. Pass --project <projectId> or run `athenode auth add-token <token>`.');
59
+ process.exitCode = 1;
60
+ return;
61
+ }
62
+ if (!query || !query.trim()) {
63
+ console.error('Error: search query must not be empty.');
64
+ process.exitCode = 1;
65
+ return;
66
+ }
67
+ const res = await (0, apiClient_1.apiRequest)(`/api/projects/${projectId}/specifications/search?q=${encodeURIComponent(query)}`);
68
+ if ((0, errors_1.handleApiError)(res)) {
69
+ return;
70
+ }
71
+ if (options.json) {
72
+ (0, output_1.printJson)(res.body);
73
+ }
74
+ else {
75
+ console.log((0, output_1.formatSpecificationSearchResults)(res.body));
76
+ }
77
+ });
78
+ }
79
+ async function specsGetCommand(id, options) {
80
+ await (0, errors_1.withErrorHandling)(async () => {
81
+ const res = await (0, apiClient_1.apiRequest)(`/api/specifications/${id}`);
82
+ if ((0, errors_1.handleApiError)(res)) {
83
+ return;
84
+ }
85
+ if (options.json) {
86
+ (0, output_1.printJson)(res.body);
87
+ }
88
+ else {
89
+ console.log((0, output_1.formatSpecification)(res.body));
90
+ }
91
+ });
92
+ }
93
+ async function specsCreateCommand(options) {
94
+ await (0, errors_1.withErrorHandling)(async () => {
95
+ const projectId = options.project ?? (0, apiClient_1.getStoredProjectId)();
96
+ if (!projectId) {
97
+ console.error('Error: no project id available. Pass --project <projectId> or run `athenode auth add-token <token>`.');
98
+ process.exitCode = 1;
99
+ return;
100
+ }
101
+ const body = {
102
+ projectId,
103
+ title: options.title,
104
+ };
105
+ if (options.parent !== undefined) {
106
+ body['parentId'] = options.parent;
107
+ }
108
+ if (options.summary !== undefined) {
109
+ body['summary'] = options.summary;
110
+ }
111
+ if (options.content !== undefined) {
112
+ body['content'] = options.content;
113
+ }
114
+ if (options.status !== undefined) {
115
+ body['status'] = options.status;
116
+ }
117
+ const res = await (0, apiClient_1.apiRequest)('/api/specifications', { method: 'POST', body });
118
+ if ((0, errors_1.handleApiError)(res)) {
119
+ return;
120
+ }
121
+ if (options.json) {
122
+ (0, output_1.printJson)(res.body);
123
+ }
124
+ else {
125
+ console.log((0, output_1.formatSpecification)(res.body));
126
+ }
127
+ });
128
+ }
129
+ /**
130
+ * Builds a partial-update body containing only fields the user actually supplied, matching the
131
+ * backend's presence-tracked `SpecificationUpdateRequest` semantics (a field's key is omitted
132
+ * entirely unless the user explicitly set or cleared it).
133
+ */
134
+ function buildUpdateBody(options) {
135
+ const body = {};
136
+ if (options.clearParent) {
137
+ body['parentId'] = null;
138
+ }
139
+ else if (options.parent !== undefined) {
140
+ body['parentId'] = options.parent;
141
+ }
142
+ if (options.title !== undefined) {
143
+ body['title'] = options.title;
144
+ }
145
+ if (options.clearSummary) {
146
+ body['summary'] = null;
147
+ }
148
+ else if (options.summary !== undefined) {
149
+ body['summary'] = options.summary;
150
+ }
151
+ if (options.clearContent) {
152
+ body['content'] = null;
153
+ }
154
+ else if (options.content !== undefined) {
155
+ body['content'] = options.content;
156
+ }
157
+ return body;
158
+ }
159
+ async function specsUpdateCommand(id, options) {
160
+ await (0, errors_1.withErrorHandling)(async () => {
161
+ const body = buildUpdateBody(options);
162
+ if (Object.keys(body).length === 0) {
163
+ console.error('Error: no fields to update were supplied.');
164
+ process.exitCode = 1;
165
+ return;
166
+ }
167
+ const res = await (0, apiClient_1.apiRequest)(`/api/specifications/${id}`, { method: 'PATCH', body });
168
+ if ((0, errors_1.handleApiError)(res)) {
169
+ return;
170
+ }
171
+ if (options.json) {
172
+ (0, output_1.printJson)(res.body);
173
+ }
174
+ else {
175
+ console.log((0, output_1.formatSpecification)(res.body));
176
+ }
177
+ });
178
+ }
179
+ async function specsSetStatusCommand(id, status, options) {
180
+ await (0, errors_1.withErrorHandling)(async () => {
181
+ const res = await (0, apiClient_1.apiRequest)(`/api/specifications/${id}`, {
182
+ method: 'PATCH',
183
+ body: { status },
184
+ });
185
+ if ((0, errors_1.handleApiError)(res)) {
186
+ return;
187
+ }
188
+ if (options.json) {
189
+ (0, output_1.printJson)(res.body);
190
+ }
191
+ else {
192
+ console.log((0, output_1.formatSpecification)(res.body));
193
+ }
194
+ });
195
+ }
196
+ async function specsSetPlanCommand(id, options) {
197
+ await (0, errors_1.withErrorHandling)(async () => {
198
+ if (!options.clearPlan && options.plan === undefined) {
199
+ console.error('Error: pass --plan <text> or --clear-plan.');
200
+ process.exitCode = 1;
201
+ return;
202
+ }
203
+ const body = {
204
+ implementationPlan: options.clearPlan ? null : options.plan,
205
+ };
206
+ const res = await (0, apiClient_1.apiRequest)(`/api/specifications/${id}`, { method: 'PATCH', body });
207
+ if ((0, errors_1.handleApiError)(res)) {
208
+ return;
209
+ }
210
+ if (options.json) {
211
+ (0, output_1.printJson)(res.body);
212
+ }
213
+ else {
214
+ console.log((0, output_1.formatSpecification)(res.body));
215
+ }
216
+ });
217
+ }
218
+ async function specsGetPlanCommand(id, options) {
219
+ await (0, errors_1.withErrorHandling)(async () => {
220
+ const res = await (0, apiClient_1.apiRequest)(`/api/specifications/${id}`);
221
+ if ((0, errors_1.handleApiError)(res)) {
222
+ return;
223
+ }
224
+ if (options.json) {
225
+ (0, output_1.printJson)({ implementationPlan: res.body.implementationPlan ?? null });
226
+ }
227
+ else {
228
+ console.log(res.body.implementationPlan ?? '(no implementation plan set)');
229
+ }
230
+ });
231
+ }
package/dist/config.js ADDED
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getConfigDir = getConfigDir;
4
+ exports.getConfigPath = getConfigPath;
5
+ exports.configExists = configExists;
6
+ exports.readConfig = readConfig;
7
+ exports.writeConfig = writeConfig;
8
+ exports.updateConfig = updateConfig;
9
+ exports.ensureGitignoreEntry = ensureGitignoreEntry;
10
+ const tslib_1 = require("tslib");
11
+ const fs = tslib_1.__importStar(require("fs"));
12
+ const path = tslib_1.__importStar(require("path"));
13
+ const CONFIG_DIR_NAME = '.athenode';
14
+ const CONFIG_FILE_NAME = 'config.json';
15
+ const RESTRICTIVE_MODE = 0o600;
16
+ /** Absolute path to the `.athenode` directory for the current working directory. */
17
+ function getConfigDir(cwd = process.cwd()) {
18
+ return path.join(cwd, CONFIG_DIR_NAME);
19
+ }
20
+ /** Absolute path to the `.athenode/config.json` file for the current working directory. */
21
+ function getConfigPath(cwd = process.cwd()) {
22
+ return path.join(getConfigDir(cwd), CONFIG_FILE_NAME);
23
+ }
24
+ /** Whether a config file already exists in the current project. */
25
+ function configExists(cwd = process.cwd()) {
26
+ return fs.existsSync(getConfigPath(cwd));
27
+ }
28
+ /** Reads and parses the config file, returning an empty config if it does not exist. */
29
+ function readConfig(cwd = process.cwd()) {
30
+ const configPath = getConfigPath(cwd);
31
+ if (!fs.existsSync(configPath)) {
32
+ return {};
33
+ }
34
+ const raw = fs.readFileSync(configPath, 'utf8');
35
+ if (!raw.trim()) {
36
+ return {};
37
+ }
38
+ return JSON.parse(raw);
39
+ }
40
+ /**
41
+ * Writes the config file, creating the `.athenode` directory if needed, and enforcing
42
+ * restrictive (0600) permissions regardless of whether the file previously existed.
43
+ */
44
+ function writeConfig(config, cwd = process.cwd()) {
45
+ const configDir = getConfigDir(cwd);
46
+ const configPath = getConfigPath(cwd);
47
+ fs.mkdirSync(configDir, { recursive: true });
48
+ fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: RESTRICTIVE_MODE });
49
+ // writeFileSync's `mode` option only applies at file-creation time; if the file already
50
+ // existed, its permissions may have been looser, so re-apply them explicitly here.
51
+ fs.chmodSync(configPath, RESTRICTIVE_MODE);
52
+ }
53
+ /** Merges `partial` into the existing config (if any) and writes the result back. */
54
+ function updateConfig(partial, cwd = process.cwd()) {
55
+ const merged = { ...readConfig(cwd), ...partial };
56
+ writeConfig(merged, cwd);
57
+ return merged;
58
+ }
59
+ const GITIGNORE_ENTRY = `${CONFIG_DIR_NAME}/`;
60
+ /**
61
+ * Ensures the project's `.gitignore` (at `cwd`) contains an entry for the `.athenode/`
62
+ * directory. Idempotent: does nothing if an entry is already present. Creates the file if
63
+ * it doesn't exist.
64
+ */
65
+ function ensureGitignoreEntry(cwd = process.cwd()) {
66
+ const gitignorePath = path.join(cwd, '.gitignore');
67
+ if (!fs.existsSync(gitignorePath)) {
68
+ fs.writeFileSync(gitignorePath, `${GITIGNORE_ENTRY}\n`);
69
+ return;
70
+ }
71
+ const contents = fs.readFileSync(gitignorePath, 'utf8');
72
+ const alreadyIgnored = contents
73
+ .split('\n')
74
+ .map((line) => line.trim())
75
+ .some((line) => line === GITIGNORE_ENTRY || line === CONFIG_DIR_NAME);
76
+ if (alreadyIgnored) {
77
+ return;
78
+ }
79
+ const needsLeadingNewline = contents.length > 0 && !contents.endsWith('\n');
80
+ const suffix = `${needsLeadingNewline ? '\n' : ''}${GITIGNORE_ENTRY}\n`;
81
+ fs.appendFileSync(gitignorePath, suffix);
82
+ }
package/dist/env.js ADDED
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_API_URL = void 0;
4
+ /**
5
+ * Production API origin. Swapped in for `env.ts` by the CI publish job (see
6
+ * .gitlab-ci.yml) before building, so published npm releases point here by default.
7
+ * `ATHENODE_API_URL` always overrides this at runtime.
8
+ */
9
+ exports.DEFAULT_API_URL = 'https://api.athenode.com';
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_API_URL = void 0;
4
+ /**
5
+ * Production API origin. Swapped in for `env.ts` by the CI publish job (see
6
+ * .gitlab-ci.yml) before building, so published npm releases point here by default.
7
+ * `ATHENODE_API_URL` always overrides this at runtime.
8
+ */
9
+ exports.DEFAULT_API_URL = 'https://api.athenode.com';
package/dist/errors.js ADDED
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleApiError = handleApiError;
4
+ exports.withErrorHandling = withErrorHandling;
5
+ const apiClient_1 = require("./apiClient");
6
+ /**
7
+ * Extracts a human-readable message from a REST error body. The backend's error responses
8
+ * are shaped like `{ "message": "..." }` (or similar); fall back to a generic description if
9
+ * the body doesn't match that shape.
10
+ */
11
+ function extractMessage(body) {
12
+ if (body && typeof body === 'object' && 'message' in body) {
13
+ const message = body.message;
14
+ if (typeof message === 'string' && message.trim()) {
15
+ return message;
16
+ }
17
+ }
18
+ if (typeof body === 'string' && body.trim()) {
19
+ return body;
20
+ }
21
+ return undefined;
22
+ }
23
+ /**
24
+ * Maps a REST response status code to a human-readable error prefix, per the CLI's
25
+ * error-handling convention:
26
+ * 400 -> validation error, 401/403 -> auth error, 404 -> not found, 5xx -> server error.
27
+ */
28
+ function statusLabel(status) {
29
+ if (status === 400) {
30
+ return 'Validation error';
31
+ }
32
+ if (status === 401 || status === 403) {
33
+ return 'Authentication error';
34
+ }
35
+ if (status === 404) {
36
+ return 'Not found';
37
+ }
38
+ if (status >= 500) {
39
+ return 'Server error';
40
+ }
41
+ return 'Request failed';
42
+ }
43
+ /**
44
+ * Checks whether `response` represents a REST failure (non-2xx status). If so, prints a
45
+ * consistent error message to stderr, sets `process.exitCode = 1`, and returns `true` so the
46
+ * caller can stop further processing. Returns `false` for 2xx responses.
47
+ */
48
+ function handleApiError(response) {
49
+ if (response.status >= 200 && response.status < 300) {
50
+ return false;
51
+ }
52
+ const message = extractMessage(response.body);
53
+ const label = statusLabel(response.status);
54
+ console.error(`Error: ${label} (${response.status})${message ? `: ${message}` : ''}`);
55
+ process.exitCode = 1;
56
+ return true;
57
+ }
58
+ /**
59
+ * Runs `fn`, translating a thrown {@link MissingTokenError} into a consistent CLI error message
60
+ * (rather than an uncaught stack trace) and setting `process.exitCode = 1`.
61
+ */
62
+ async function withErrorHandling(fn) {
63
+ try {
64
+ await fn();
65
+ }
66
+ catch (err) {
67
+ if (err instanceof apiClient_1.MissingTokenError) {
68
+ console.error(`Error: ${err.message}`);
69
+ process.exitCode = 1;
70
+ return;
71
+ }
72
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
73
+ process.exitCode = 1;
74
+ }
75
+ }
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.planInstall = planInstall;
4
+ exports.writeInstallEntry = writeInstallEntry;
5
+ const tslib_1 = require("tslib");
6
+ const fs = tslib_1.__importStar(require("fs"));
7
+ const path = tslib_1.__importStar(require("path"));
8
+ /**
9
+ * Plans the on-disk writes for `items` into `targetDir` (relative to `cwd`). Read-only: does
10
+ * not touch the filesystem beyond checking for existing files.
11
+ */
12
+ function planInstall(items, targetDir, cwd = process.cwd()) {
13
+ const entries = [];
14
+ for (const item of items) {
15
+ const targetPath = path.join(cwd, targetDir, `${item.name}.md`);
16
+ const exists = fs.existsSync(targetPath);
17
+ let conflict = false;
18
+ if (exists) {
19
+ const existingContent = fs.readFileSync(targetPath, 'utf8');
20
+ conflict = existingContent !== item.content;
21
+ }
22
+ entries.push({ item, targetPath, exists, conflict });
23
+ }
24
+ return entries;
25
+ }
26
+ /**
27
+ * Writes (or skips) a single planned install entry. `overwrite` creates the target directory
28
+ * as needed and writes `entry.item.content` verbatim; `skip` is a no-op left to the caller
29
+ * to log.
30
+ */
31
+ function writeInstallEntry(entry, resolution) {
32
+ if (resolution === 'skip') {
33
+ return;
34
+ }
35
+ fs.mkdirSync(path.dirname(entry.targetPath), { recursive: true });
36
+ fs.writeFileSync(entry.targetPath, entry.item.content);
37
+ }
package/dist/index.js ADDED
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const commander_1 = require("commander");
5
+ const init_1 = require("./commands/init");
6
+ const auth_1 = require("./commands/auth");
7
+ const specs_1 = require("./commands/specs");
8
+ const executionResults_1 = require("./commands/executionResults");
9
+ const questions_1 = require("./commands/questions");
10
+ const program = new commander_1.Command();
11
+ program.name('athenode').description('CLI for athenode: project scaffold, config, and tokens.');
12
+ program
13
+ .command('init')
14
+ .description('Interactively set up athenode in this project: import/validate a project token, ' +
15
+ 'fetch the active agent setup, pick which sub-agents to install, and write the ' +
16
+ 'resulting command/sub-agent files locally (with conflict prompts).')
17
+ .action(async () => {
18
+ await (0, init_1.initCommand)();
19
+ });
20
+ const auth = program.command('auth').description('Manage authentication for the athenode CLI.');
21
+ auth
22
+ .command('add-token <token>')
23
+ .description('Validate an already-minted project token and store it (and its resolved project id) in the project-local config.')
24
+ .action(async (token) => {
25
+ await (0, auth_1.addTokenCommand)(token);
26
+ });
27
+ const specs = program.command('specs').description('Manage specifications via the athenode REST API.');
28
+ specs
29
+ .command('list')
30
+ .description('List all specifications in a project.')
31
+ .option('--project <projectId>', 'Project id (defaults to the one stored via `auth add-token`).')
32
+ .option('--status <status>', 'Filter by status (draft | approved | processing | done).')
33
+ .option('--parent <parentId>', 'Filter by parent specification id.')
34
+ .option('--top-level', 'List only top-level specifications (those with no parent). Mutually exclusive with --parent.')
35
+ .option('--json', 'Print raw JSON instead of a human-readable table.')
36
+ .action(async (options) => {
37
+ await (0, specs_1.specsListCommand)(options);
38
+ });
39
+ specs
40
+ .command('search <query>')
41
+ .description('Full-text search specifications in a project.')
42
+ .option('--project <projectId>', 'Project id (defaults to the one stored via `auth add-token`).')
43
+ .option('--json', 'Print raw JSON instead of a human-readable table.')
44
+ .action(async (query, options) => {
45
+ await (0, specs_1.specsSearchCommand)(query, options);
46
+ });
47
+ specs
48
+ .command('get <id>')
49
+ .description('Get a single specification by id.')
50
+ .option('--json', 'Print raw JSON instead of a human-readable block.')
51
+ .action(async (id, options) => {
52
+ await (0, specs_1.specsGetCommand)(id, options);
53
+ });
54
+ specs
55
+ .command('create')
56
+ .description('Create a new specification.')
57
+ .requiredOption('--title <title>', 'Specification title.')
58
+ .option('--project <projectId>', 'Project id (defaults to the one stored via `auth add-token`).')
59
+ .option('--parent <parentId>', 'Parent specification id.')
60
+ .option('--summary <summary>', 'Short summary.')
61
+ .option('--content <content>', 'Full specification content.')
62
+ .option('--status <status>', 'Initial status (draft | approved | processing | done).')
63
+ .option('--json', 'Print raw JSON instead of a human-readable block.')
64
+ .action(async (options) => {
65
+ await (0, specs_1.specsCreateCommand)(options);
66
+ });
67
+ specs
68
+ .command('update <id>')
69
+ .description('Update fields of an existing specification (only supplied fields are changed).')
70
+ .option('--title <title>', 'New title.')
71
+ .option('--summary <summary>', 'New summary.')
72
+ .option('--clear-summary', 'Clear the summary field.')
73
+ .option('--content <content>', 'New content.')
74
+ .option('--clear-content', 'Clear the content field.')
75
+ .option('--parent <parentId>', 'New parent specification id.')
76
+ .option('--clear-parent', 'Clear the parent specification id.')
77
+ .option('--json', 'Print raw JSON instead of a human-readable block.')
78
+ .action(async (id, options) => {
79
+ await (0, specs_1.specsUpdateCommand)(id, options);
80
+ });
81
+ specs
82
+ .command('set-status <id> <status>')
83
+ .description('Set the status of a specification (draft | approved | processing | done).')
84
+ .option('--json', 'Print raw JSON instead of a human-readable block.')
85
+ .action(async (id, status, options) => {
86
+ await (0, specs_1.specsSetStatusCommand)(id, status, options);
87
+ });
88
+ specs
89
+ .command('set-plan <id>')
90
+ .description('Set (or clear) the implementation plan of a specification.')
91
+ .option('--plan <text>', 'Implementation plan text.')
92
+ .option('--clear-plan', 'Clear the implementation plan.')
93
+ .option('--json', 'Print raw JSON instead of a human-readable block.')
94
+ .action(async (id, options) => {
95
+ await (0, specs_1.specsSetPlanCommand)(id, options);
96
+ });
97
+ specs
98
+ .command('get-plan <id>')
99
+ .description('Get the implementation plan of a specification.')
100
+ .option('--json', 'Print raw JSON instead of plain text.')
101
+ .action(async (id, options) => {
102
+ await (0, specs_1.specsGetPlanCommand)(id, options);
103
+ });
104
+ const specsResults = specs
105
+ .command('results')
106
+ .description('Manage specification execution results via the athenode REST API.');
107
+ specsResults
108
+ .command('list <specificationId>')
109
+ .description('List execution results recorded for a specification.')
110
+ .option('--json', 'Print raw JSON instead of a human-readable table.')
111
+ .action(async (specificationId, options) => {
112
+ await (0, executionResults_1.executionResultsListCommand)(specificationId, options);
113
+ });
114
+ specsResults
115
+ .command('create <specificationId>')
116
+ .description('Record a new execution result for a specification.')
117
+ .requiredOption('--file-path <filePath>', 'Path of the file that was changed.')
118
+ .requiredOption('--summary <summary>', 'Short description of the change.')
119
+ .option('--json', 'Print raw JSON instead of a human-readable block.')
120
+ .action(async (specificationId, options) => {
121
+ await (0, executionResults_1.executionResultsCreateCommand)(specificationId, options);
122
+ });
123
+ const specsQa = specs
124
+ .command('qa')
125
+ .description('Manage specification questions via the athenode REST API.');
126
+ specsQa
127
+ .command('list <specificationId>')
128
+ .description('List questions recorded for a specification.')
129
+ .option('--json', 'Print raw JSON instead of a human-readable table.')
130
+ .action(async (specificationId, options) => {
131
+ await (0, questions_1.questionsListCommand)(specificationId, options);
132
+ });
133
+ specsQa
134
+ .command('add <specificationId>')
135
+ .description('Add a new question for a specification.')
136
+ .requiredOption('--question <question>', 'The question text.')
137
+ .option('--json', 'Print raw JSON instead of a human-readable block.')
138
+ .action(async (specificationId, options) => {
139
+ await (0, questions_1.questionsAddCommand)(specificationId, options);
140
+ });
141
+ specsQa
142
+ .command('answer <specificationId> <questionId>')
143
+ .description('Answer a previously recorded question.')
144
+ .requiredOption('--answer <answer>', 'The answer text.')
145
+ .option('--json', 'Print raw JSON instead of a human-readable block.')
146
+ .action(async (specificationId, questionId, options) => {
147
+ await (0, questions_1.questionsAnswerCommand)(specificationId, questionId, options);
148
+ });
149
+ program.parse(process.argv);
package/dist/output.js ADDED
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ /**
3
+ * Shared output formatting for CLI commands. Every command supports human-readable output by
4
+ * default and a `--json` flag for raw machine-readable JSON, per the CLI's command
5
+ * conventions.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.printJson = printJson;
9
+ exports.formatSpecification = formatSpecification;
10
+ exports.formatSpecificationList = formatSpecificationList;
11
+ exports.formatSpecificationSearchResults = formatSpecificationSearchResults;
12
+ exports.formatExecutionResult = formatExecutionResult;
13
+ exports.formatExecutionResultList = formatExecutionResultList;
14
+ exports.formatQuestion = formatQuestion;
15
+ exports.formatQuestionList = formatQuestionList;
16
+ /** Prints `body` as raw JSON (pretty-printed), used for every command's `--json` mode. */
17
+ function printJson(body) {
18
+ console.log(JSON.stringify(body, null, 2));
19
+ }
20
+ /** Renders a single specification as a human-readable text block. */
21
+ function formatSpecification(spec) {
22
+ const lines = [
23
+ `id: ${spec.id}`,
24
+ `parentId: ${spec.parentId ?? '-'}`,
25
+ `title: ${spec.title}`,
26
+ `status: ${spec.status}`,
27
+ `summary: ${spec.summary ?? '-'}`,
28
+ `hasChildren: ${spec.hasChildren}`,
29
+ ];
30
+ if (spec.createdAt) {
31
+ lines.push(`createdAt: ${spec.createdAt}`);
32
+ }
33
+ if (spec.updatedAt) {
34
+ lines.push(`updatedAt: ${spec.updatedAt}`);
35
+ }
36
+ return lines.join('\n');
37
+ }
38
+ /**
39
+ * Renders a list of specifications as a human-readable block: each specification's fields
40
+ * one-per-line, items separated by a `---[spec_id:<id>]---` delimiter.
41
+ */
42
+ function formatSpecificationList(specs) {
43
+ if (specs.length === 0) {
44
+ return '(no specifications)';
45
+ }
46
+ return specs
47
+ .map((spec) => [
48
+ `---[spec_id:${spec.id}]---`,
49
+ `parentId: ${spec.parentId ?? '-'}`,
50
+ `status: ${spec.status}`,
51
+ `title: ${spec.title}`,
52
+ `summary: ${spec.summary ?? '-'}`,
53
+ `hasChildren: ${spec.hasChildren}`,
54
+ ].join('\n'))
55
+ .join('\n');
56
+ }
57
+ /**
58
+ * Renders specification search results as a human-readable block: each result's fields
59
+ * one-per-line, items separated by a `---[spec_id:<id>]---` delimiter (same shape as
60
+ * `formatSpecificationList`, plus `rank`).
61
+ */
62
+ function formatSpecificationSearchResults(results) {
63
+ if (results.length === 0) {
64
+ return '(no results)';
65
+ }
66
+ return results
67
+ .map((r) => [
68
+ `---[spec_id:${r.id}]---`,
69
+ `parentId: ${r.parentId ?? '-'}`,
70
+ `status: ${r.status}`,
71
+ `title: ${r.title}`,
72
+ `summary: ${r.summary ?? '-'}`,
73
+ `hasChildren: ${r.hasChildren}`,
74
+ `rank: ${r.rank.toFixed(3)}`,
75
+ ].join('\n'))
76
+ .join('\n');
77
+ }
78
+ /** Renders a single execution result as a human-readable text block. */
79
+ function formatExecutionResult(result) {
80
+ return [
81
+ `id: ${result.id}`,
82
+ `specificationId: ${result.specificationId}`,
83
+ `filePath: ${result.filePath}`,
84
+ `summary: ${result.summary}`,
85
+ ...(result.createdAt ? [`createdAt: ${result.createdAt}`] : []),
86
+ ].join('\n');
87
+ }
88
+ /**
89
+ * Renders a list of execution results as a human-readable block: each result's fields
90
+ * one-per-line, items separated by a `---[result_id:<id>]---` delimiter.
91
+ */
92
+ function formatExecutionResultList(results) {
93
+ if (results.length === 0) {
94
+ return '(no execution results)';
95
+ }
96
+ return results
97
+ .map((r) => [
98
+ `---[result_id:${r.id}]---`,
99
+ `specificationId: ${r.specificationId}`,
100
+ `filePath: ${r.filePath}`,
101
+ `summary: ${r.summary}`,
102
+ ...(r.createdAt ? [`createdAt: ${r.createdAt}`] : []),
103
+ ].join('\n'))
104
+ .join('\n');
105
+ }
106
+ /** Renders a single specification question as a human-readable text block. */
107
+ function formatQuestion(question) {
108
+ return [
109
+ `id: ${question.id}`,
110
+ `specificationId: ${question.specificationId}`,
111
+ `question: ${question.question}`,
112
+ `answer: ${question.answer ?? '(unanswered)'}`,
113
+ ...(question.createdAt ? [`createdAt: ${question.createdAt}`] : []),
114
+ ].join('\n');
115
+ }
116
+ /**
117
+ * Renders a list of specification questions as a human-readable block: each question's fields
118
+ * one-per-line, items separated by a `---[question_id:<id>]---` delimiter. `answer` shows the
119
+ * full recorded answer text, or `(unanswered)` if none has been recorded yet.
120
+ */
121
+ function formatQuestionList(questions) {
122
+ if (questions.length === 0) {
123
+ return '(no questions)';
124
+ }
125
+ return questions
126
+ .map((q) => [
127
+ `---[question_id:${q.id}]---`,
128
+ `specificationId: ${q.specificationId}`,
129
+ `question: ${q.question}`,
130
+ `answer: ${q.answer ?? '(unanswered)'}`,
131
+ ...(q.createdAt ? [`createdAt: ${q.createdAt}`] : []),
132
+ ].join('\n'))
133
+ .join('\n');
134
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@athenode/cli",
3
+ "version": "0.0.1",
4
+ "description": "Command-line interface for athenode: project scaffold, local config, and token storage.",
5
+ "bin": {
6
+ "athenode": "dist/index.js"
7
+ },
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "packageManager": "npm@11.13.0",
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "start": "node dist/index.js",
18
+ "dev": "tsc --watch"
19
+ },
20
+ "dependencies": {
21
+ "@clack/prompts": "^0.7.0",
22
+ "commander": "^12.1.0",
23
+ "tslib": "^2.8.1"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^22.10.2",
27
+ "typescript": "~6.0.2"
28
+ }
29
+ }