@outputai/cli 0.1.13-next.f537949.0 → 0.2.1-next.af8a069.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.
- package/dist/assets/docker/docker-compose-dev.yml +2 -2
- package/dist/commands/credentials/set.d.ts +16 -0
- package/dist/commands/credentials/set.js +125 -0
- package/dist/commands/credentials/set.spec.d.ts +1 -0
- package/dist/commands/credentials/set.spec.js +160 -0
- package/dist/commands/migrate.d.ts +11 -0
- package/dist/commands/migrate.js +40 -0
- package/dist/commands/workflow/plan.js +1 -1
- package/dist/generated/framework_version.json +1 -1
- package/dist/services/claude_client.d.ts +14 -2
- package/dist/services/claude_client.integration.test.js +2 -2
- package/dist/services/claude_client.js +38 -5
- package/dist/services/claude_client.spec.js +3 -3
- package/dist/services/workflow_builder.d.ts +1 -1
- package/dist/services/workflow_builder.js +1 -1
- package/package.json +4 -4
|
@@ -81,7 +81,7 @@ services:
|
|
|
81
81
|
condition: service_healthy
|
|
82
82
|
worker:
|
|
83
83
|
condition: service_healthy
|
|
84
|
-
image: outputai/api:${OUTPUT_API_VERSION:-0.1
|
|
84
|
+
image: outputai/api:${OUTPUT_API_VERSION:-0.2.1-next.af8a069.0}
|
|
85
85
|
init: true
|
|
86
86
|
networks:
|
|
87
87
|
- main
|
|
@@ -103,7 +103,7 @@ services:
|
|
|
103
103
|
depends_on:
|
|
104
104
|
temporal:
|
|
105
105
|
condition: service_healthy
|
|
106
|
-
image: node:24.
|
|
106
|
+
image: node:24.15.0-slim
|
|
107
107
|
healthcheck:
|
|
108
108
|
test: [ 'CMD-SHELL', 'npx output-healthcheck' ]
|
|
109
109
|
interval: 3s
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class CredentialsSet extends Command {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static args: {
|
|
6
|
+
path: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
7
|
+
value: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
8
|
+
};
|
|
9
|
+
static flags: {
|
|
10
|
+
environment: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
workflow: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
13
|
+
};
|
|
14
|
+
private confirmOverwrite;
|
|
15
|
+
run(): Promise<void>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { Args, Command, Flags } from '@oclif/core';
|
|
2
|
+
import { confirm } from '@inquirer/prompts';
|
|
3
|
+
import { load as parseYaml, dump as stringifyYaml } from 'js-yaml';
|
|
4
|
+
import { getErrorMessage } from '#utils/error_utils.js';
|
|
5
|
+
import { decryptCredentials, credentialsExist, writeEncrypted, resolveCredentialsPath } from '#services/credentials_service.js';
|
|
6
|
+
const isPlainObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
7
|
+
const detectPathConflict = (obj, dotPath) => {
|
|
8
|
+
const parts = dotPath.split('.');
|
|
9
|
+
const intermediateKeys = parts.slice(0, -1);
|
|
10
|
+
const leafKey = parts[parts.length - 1];
|
|
11
|
+
const walked = intermediateKeys.reduce((state, key, i) => {
|
|
12
|
+
if (state.done) {
|
|
13
|
+
return state;
|
|
14
|
+
}
|
|
15
|
+
const next = state.cursor[key];
|
|
16
|
+
if (next === undefined) {
|
|
17
|
+
return { done: true, conflict: null };
|
|
18
|
+
}
|
|
19
|
+
if (!isPlainObject(next)) {
|
|
20
|
+
return {
|
|
21
|
+
done: true,
|
|
22
|
+
conflict: {
|
|
23
|
+
kind: 'primitive_to_object',
|
|
24
|
+
atPath: parts.slice(0, i + 1).join('.'),
|
|
25
|
+
existingValue: next
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return { done: false, cursor: next };
|
|
30
|
+
}, { done: false, cursor: obj });
|
|
31
|
+
if (walked.done) {
|
|
32
|
+
return walked.conflict;
|
|
33
|
+
}
|
|
34
|
+
const leaf = walked.cursor[leafKey];
|
|
35
|
+
if (leaf !== undefined && (isPlainObject(leaf) || Array.isArray(leaf))) {
|
|
36
|
+
return { kind: 'object_to_primitive', atPath: dotPath, existingValue: leaf };
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
};
|
|
40
|
+
const setNestedValue = (obj, dotPath, value) => {
|
|
41
|
+
const parts = dotPath.split('.');
|
|
42
|
+
const parent = parts.slice(0, -1).reduce((current, key) => {
|
|
43
|
+
if (!isPlainObject(current[key])) {
|
|
44
|
+
current[key] = {};
|
|
45
|
+
}
|
|
46
|
+
return current[key];
|
|
47
|
+
}, obj);
|
|
48
|
+
parent[parts[parts.length - 1]] = value;
|
|
49
|
+
};
|
|
50
|
+
export default class CredentialsSet extends Command {
|
|
51
|
+
static description = 'Set a credential value by dot-notation path';
|
|
52
|
+
static examples = [
|
|
53
|
+
'<%= config.bin %> <%= command.id %> anthropic.api_key sk-ant-...',
|
|
54
|
+
'<%= config.bin %> <%= command.id %> openai.api_key sk-... --environment production',
|
|
55
|
+
'<%= config.bin %> <%= command.id %> stripe.key sk_live_... --workflow my_workflow'
|
|
56
|
+
];
|
|
57
|
+
static args = {
|
|
58
|
+
path: Args.string({
|
|
59
|
+
description: 'Dot-notation path to the credential (e.g. anthropic.api_key)',
|
|
60
|
+
required: true
|
|
61
|
+
}),
|
|
62
|
+
value: Args.string({
|
|
63
|
+
description: 'Value to set',
|
|
64
|
+
required: true
|
|
65
|
+
})
|
|
66
|
+
};
|
|
67
|
+
static flags = {
|
|
68
|
+
environment: Flags.string({
|
|
69
|
+
char: 'e',
|
|
70
|
+
description: 'Target environment (e.g. production, development)'
|
|
71
|
+
}),
|
|
72
|
+
workflow: Flags.string({
|
|
73
|
+
char: 'w',
|
|
74
|
+
description: 'Target a specific workflow directory'
|
|
75
|
+
}),
|
|
76
|
+
yes: Flags.boolean({
|
|
77
|
+
char: 'y',
|
|
78
|
+
description: 'Skip confirmation prompts when overwriting a value of a different shape',
|
|
79
|
+
default: false
|
|
80
|
+
})
|
|
81
|
+
};
|
|
82
|
+
async confirmOverwrite(conflict, newPath) {
|
|
83
|
+
if (conflict.kind === 'primitive_to_object') {
|
|
84
|
+
this.warn(`Writing to "${newPath}" will convert "${conflict.atPath}" from a value into an object, ` +
|
|
85
|
+
`discarding its current value (${JSON.stringify(conflict.existingValue)}).`);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
this.warn(`Writing to "${newPath}" will replace the existing object at that path ` +
|
|
89
|
+
`(${JSON.stringify(conflict.existingValue)}) with a string value.`);
|
|
90
|
+
}
|
|
91
|
+
return confirm({ message: 'Continue?', default: false });
|
|
92
|
+
}
|
|
93
|
+
async run() {
|
|
94
|
+
const { args, flags } = await this.parse(CredentialsSet);
|
|
95
|
+
const environment = flags.environment;
|
|
96
|
+
const workflow = flags.workflow;
|
|
97
|
+
if (environment && workflow) {
|
|
98
|
+
this.error('Cannot specify both --environment and --workflow.');
|
|
99
|
+
}
|
|
100
|
+
if (!credentialsExist(environment, workflow)) {
|
|
101
|
+
this.error(`No credentials file found at ${resolveCredentialsPath(environment, workflow)}. Run "output credentials init" first.`);
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const plaintext = decryptCredentials(environment, workflow);
|
|
105
|
+
const data = (parseYaml(plaintext) || {});
|
|
106
|
+
const conflict = detectPathConflict(data, args.path);
|
|
107
|
+
if (conflict && !flags.yes) {
|
|
108
|
+
const shouldContinue = await this.confirmOverwrite(conflict, args.path);
|
|
109
|
+
if (!shouldContinue) {
|
|
110
|
+
this.log('Aborted.');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
setNestedValue(data, args.path, args.value);
|
|
115
|
+
writeEncrypted(environment, stringifyYaml(data), workflow);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (error instanceof Error && error.constructor.name === 'ExitPromptError') {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
this.error(`Failed to update credentials: ${getErrorMessage(error)}`);
|
|
122
|
+
}
|
|
123
|
+
this.log(`Set ${args.path}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
3
|
+
import { confirm } from '@inquirer/prompts';
|
|
4
|
+
import * as credentialsService from '#services/credentials_service.js';
|
|
5
|
+
import CredentialsSet from './set.js';
|
|
6
|
+
vi.mock('#services/credentials_service.js');
|
|
7
|
+
vi.mock('@inquirer/prompts', () => ({
|
|
8
|
+
confirm: vi.fn()
|
|
9
|
+
}));
|
|
10
|
+
vi.mock('js-yaml', () => ({
|
|
11
|
+
load: vi.fn((yaml) => {
|
|
12
|
+
if (yaml.includes('__PRIMITIVE_AT_X_Y__')) {
|
|
13
|
+
return { x: { y: 'FOO' } };
|
|
14
|
+
}
|
|
15
|
+
if (yaml.includes('__OBJECT_AT_X_Y__')) {
|
|
16
|
+
return { x: { y: { z: 'FOO' } } };
|
|
17
|
+
}
|
|
18
|
+
if (yaml.includes('sk-existing')) {
|
|
19
|
+
return { anthropic: { api_key: 'sk-existing' } };
|
|
20
|
+
}
|
|
21
|
+
return {};
|
|
22
|
+
}),
|
|
23
|
+
dump: vi.fn((obj) => JSON.stringify(obj))
|
|
24
|
+
}));
|
|
25
|
+
describe('credentials set command', () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.clearAllMocks();
|
|
28
|
+
vi.mocked(credentialsService.credentialsExist).mockReturnValue(true);
|
|
29
|
+
vi.mocked(credentialsService.decryptCredentials).mockReturnValue('anthropic:\n api_key: sk-existing\n');
|
|
30
|
+
vi.mocked(credentialsService.writeEncrypted).mockImplementation(() => { });
|
|
31
|
+
vi.mocked(confirm).mockResolvedValue(true);
|
|
32
|
+
});
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
vi.restoreAllMocks();
|
|
35
|
+
});
|
|
36
|
+
const createTestCommand = (parsedArgs = {}, flags = {}) => {
|
|
37
|
+
const cmd = new CredentialsSet([], {});
|
|
38
|
+
cmd.log = vi.fn();
|
|
39
|
+
cmd.warn = vi.fn();
|
|
40
|
+
cmd.error = vi.fn((msg) => {
|
|
41
|
+
throw new Error(msg);
|
|
42
|
+
});
|
|
43
|
+
Object.defineProperty(cmd, 'parse', {
|
|
44
|
+
value: vi.fn().mockResolvedValue({
|
|
45
|
+
args: { path: 'anthropic.api_key', value: 'sk-new-key', ...parsedArgs },
|
|
46
|
+
flags: { environment: undefined, workflow: undefined, yes: false, ...flags }
|
|
47
|
+
}),
|
|
48
|
+
configurable: true
|
|
49
|
+
});
|
|
50
|
+
return cmd;
|
|
51
|
+
};
|
|
52
|
+
describe('command structure', () => {
|
|
53
|
+
it('should have correct description', () => {
|
|
54
|
+
expect(CredentialsSet.description).toContain('credential value');
|
|
55
|
+
});
|
|
56
|
+
it('should have required path and value arguments', () => {
|
|
57
|
+
expect(CredentialsSet.args.path).toBeDefined();
|
|
58
|
+
expect(CredentialsSet.args.path.required).toBe(true);
|
|
59
|
+
expect(CredentialsSet.args.value).toBeDefined();
|
|
60
|
+
expect(CredentialsSet.args.value.required).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
it('should have environment, workflow, and yes flags', () => {
|
|
63
|
+
expect(CredentialsSet.flags.environment).toBeDefined();
|
|
64
|
+
expect(CredentialsSet.flags.workflow).toBeDefined();
|
|
65
|
+
expect(CredentialsSet.flags.yes).toBeDefined();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe('command execution', () => {
|
|
69
|
+
it('should decrypt, update, and re-encrypt credentials', async () => {
|
|
70
|
+
const cmd = createTestCommand();
|
|
71
|
+
await cmd.run();
|
|
72
|
+
expect(credentialsService.decryptCredentials).toHaveBeenCalledWith(undefined, undefined);
|
|
73
|
+
expect(credentialsService.writeEncrypted).toHaveBeenCalledWith(undefined, expect.any(String), undefined);
|
|
74
|
+
expect(confirm).not.toHaveBeenCalled();
|
|
75
|
+
expect(cmd.log).toHaveBeenCalledWith('Set anthropic.api_key');
|
|
76
|
+
});
|
|
77
|
+
it('should create nested keys that do not exist', async () => {
|
|
78
|
+
vi.mocked(credentialsService.decryptCredentials).mockReturnValue('');
|
|
79
|
+
const cmd = createTestCommand({ path: 'new.nested.key', value: 'my-value' });
|
|
80
|
+
await cmd.run();
|
|
81
|
+
expect(credentialsService.writeEncrypted).toHaveBeenCalledTimes(1);
|
|
82
|
+
expect(confirm).not.toHaveBeenCalled();
|
|
83
|
+
expect(cmd.log).toHaveBeenCalledWith('Set new.nested.key');
|
|
84
|
+
});
|
|
85
|
+
it('should pass environment flag to service functions', async () => {
|
|
86
|
+
const cmd = createTestCommand({}, { environment: 'production' });
|
|
87
|
+
await cmd.run();
|
|
88
|
+
expect(credentialsService.credentialsExist).toHaveBeenCalledWith('production', undefined);
|
|
89
|
+
expect(credentialsService.decryptCredentials).toHaveBeenCalledWith('production', undefined);
|
|
90
|
+
expect(credentialsService.writeEncrypted).toHaveBeenCalledWith('production', expect.any(String), undefined);
|
|
91
|
+
});
|
|
92
|
+
it('should pass workflow flag to service functions', async () => {
|
|
93
|
+
const cmd = createTestCommand({}, { workflow: 'my_workflow' });
|
|
94
|
+
await cmd.run();
|
|
95
|
+
expect(credentialsService.credentialsExist).toHaveBeenCalledWith(undefined, 'my_workflow');
|
|
96
|
+
expect(credentialsService.decryptCredentials).toHaveBeenCalledWith(undefined, 'my_workflow');
|
|
97
|
+
expect(credentialsService.writeEncrypted).toHaveBeenCalledWith(undefined, expect.any(String), 'my_workflow');
|
|
98
|
+
});
|
|
99
|
+
it('should error when both environment and workflow are specified', async () => {
|
|
100
|
+
const cmd = createTestCommand({}, { environment: 'production', workflow: 'my_workflow' });
|
|
101
|
+
await expect(cmd.run()).rejects.toThrow('Cannot specify both');
|
|
102
|
+
});
|
|
103
|
+
it('should error when credentials file does not exist', async () => {
|
|
104
|
+
vi.mocked(credentialsService.credentialsExist).mockReturnValue(false);
|
|
105
|
+
vi.mocked(credentialsService.resolveCredentialsPath).mockReturnValue('/project/config/credentials.yml.enc');
|
|
106
|
+
const cmd = createTestCommand();
|
|
107
|
+
await expect(cmd.run()).rejects.toThrow('No credentials file found');
|
|
108
|
+
});
|
|
109
|
+
it('should surface a friendly error when decryption fails', async () => {
|
|
110
|
+
vi.mocked(credentialsService.decryptCredentials).mockImplementation(() => {
|
|
111
|
+
throw new Error('Invalid key');
|
|
112
|
+
});
|
|
113
|
+
const cmd = createTestCommand();
|
|
114
|
+
await expect(cmd.run()).rejects.toThrow('Failed to update credentials: Invalid key');
|
|
115
|
+
});
|
|
116
|
+
it('should surface a friendly error when encryption fails', async () => {
|
|
117
|
+
vi.mocked(credentialsService.writeEncrypted).mockImplementation(() => {
|
|
118
|
+
throw new Error('Permission denied');
|
|
119
|
+
});
|
|
120
|
+
const cmd = createTestCommand();
|
|
121
|
+
await expect(cmd.run()).rejects.toThrow('Failed to update credentials: Permission denied');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
describe('shape-change confirmation', () => {
|
|
125
|
+
it('should prompt before converting a primitive value into an object', async () => {
|
|
126
|
+
vi.mocked(credentialsService.decryptCredentials).mockReturnValue('__PRIMITIVE_AT_X_Y__');
|
|
127
|
+
const cmd = createTestCommand({ path: 'x.y.z', value: 'BAR' });
|
|
128
|
+
await cmd.run();
|
|
129
|
+
expect(cmd.warn).toHaveBeenCalledWith(expect.stringContaining('convert "x.y" from a value into an object'));
|
|
130
|
+
expect(confirm).toHaveBeenCalledTimes(1);
|
|
131
|
+
expect(credentialsService.writeEncrypted).toHaveBeenCalledTimes(1);
|
|
132
|
+
expect(cmd.log).toHaveBeenCalledWith('Set x.y.z');
|
|
133
|
+
});
|
|
134
|
+
it('should prompt before replacing an object with a primitive value', async () => {
|
|
135
|
+
vi.mocked(credentialsService.decryptCredentials).mockReturnValue('__OBJECT_AT_X_Y__');
|
|
136
|
+
const cmd = createTestCommand({ path: 'x.y', value: 'BAR' });
|
|
137
|
+
await cmd.run();
|
|
138
|
+
expect(cmd.warn).toHaveBeenCalledWith(expect.stringContaining('replace the existing object'));
|
|
139
|
+
expect(confirm).toHaveBeenCalledTimes(1);
|
|
140
|
+
expect(credentialsService.writeEncrypted).toHaveBeenCalledTimes(1);
|
|
141
|
+
});
|
|
142
|
+
it('should abort without writing when the user declines the prompt', async () => {
|
|
143
|
+
vi.mocked(credentialsService.decryptCredentials).mockReturnValue('__PRIMITIVE_AT_X_Y__');
|
|
144
|
+
vi.mocked(confirm).mockResolvedValue(false);
|
|
145
|
+
const cmd = createTestCommand({ path: 'x.y.z', value: 'BAR' });
|
|
146
|
+
await cmd.run();
|
|
147
|
+
expect(credentialsService.writeEncrypted).not.toHaveBeenCalled();
|
|
148
|
+
expect(cmd.log).toHaveBeenCalledWith('Aborted.');
|
|
149
|
+
expect(cmd.log).not.toHaveBeenCalledWith('Set x.y.z');
|
|
150
|
+
});
|
|
151
|
+
it('should skip the prompt when --yes is passed', async () => {
|
|
152
|
+
vi.mocked(credentialsService.decryptCredentials).mockReturnValue('__PRIMITIVE_AT_X_Y__');
|
|
153
|
+
const cmd = createTestCommand({ path: 'x.y.z', value: 'BAR' }, { yes: true });
|
|
154
|
+
await cmd.run();
|
|
155
|
+
expect(confirm).not.toHaveBeenCalled();
|
|
156
|
+
expect(credentialsService.writeEncrypted).toHaveBeenCalledTimes(1);
|
|
157
|
+
expect(cmd.log).toHaveBeenCalledWith('Set x.y.z');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class Migrate extends Command {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
from: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
to: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
notes: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
};
|
|
10
|
+
run(): Promise<void>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core';
|
|
2
|
+
import { ensureOutputAISystem } from '#services/coding_agents.js';
|
|
3
|
+
import { invokeMigrate } from '#services/claude_client.js';
|
|
4
|
+
export default class Migrate extends Command {
|
|
5
|
+
static description = 'Upgrade a project between versions of the Output framework. ' +
|
|
6
|
+
'Fetches the matching migration guide from docs.output.ai and applies it.';
|
|
7
|
+
static examples = [
|
|
8
|
+
'<%= config.bin %> <%= command.id %>',
|
|
9
|
+
'<%= config.bin %> <%= command.id %> --to 0.2.0',
|
|
10
|
+
'<%= config.bin %> <%= command.id %> --from 0.1.12 --to 0.2.0',
|
|
11
|
+
'<%= config.bin %> <%= command.id %> --to 0.2.0 --notes "skip the http changes, we don\'t use that package"'
|
|
12
|
+
];
|
|
13
|
+
static flags = {
|
|
14
|
+
from: Flags.string({
|
|
15
|
+
description: 'Version to migrate from. Defaults to the framework version in your package.json.',
|
|
16
|
+
required: false
|
|
17
|
+
}),
|
|
18
|
+
to: Flags.string({
|
|
19
|
+
description: 'Version to migrate to. Defaults to the latest published version.',
|
|
20
|
+
required: false
|
|
21
|
+
}),
|
|
22
|
+
notes: Flags.string({
|
|
23
|
+
char: 'n',
|
|
24
|
+
description: 'Extra guidance passed through to the migration agent.',
|
|
25
|
+
required: false
|
|
26
|
+
})
|
|
27
|
+
};
|
|
28
|
+
async run() {
|
|
29
|
+
const { flags } = await this.parse(Migrate);
|
|
30
|
+
const projectRoot = process.cwd();
|
|
31
|
+
this.log('Checking .outputai directory structure...');
|
|
32
|
+
await ensureOutputAISystem(projectRoot);
|
|
33
|
+
this.log('\nInvoking the /output-migrate skill...');
|
|
34
|
+
this.log('This may take a moment...\n');
|
|
35
|
+
const summary = await invokeMigrate(flags.from ?? '', flags.to ?? '', flags.notes);
|
|
36
|
+
this.log('\n=========');
|
|
37
|
+
this.log(summary);
|
|
38
|
+
this.log('=========\n');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -56,7 +56,7 @@ export default class WorkflowPlan extends Command {
|
|
|
56
56
|
return this.planModificationLoop(modifiedPlanContent);
|
|
57
57
|
}
|
|
58
58
|
async planGenerationLoop(promptDescription, planName, projectRoot) {
|
|
59
|
-
this.log('\nInvoking the /
|
|
59
|
+
this.log('\nInvoking the /output-plan-workflow command...');
|
|
60
60
|
this.log('This may take a moment...\n');
|
|
61
61
|
const planContent = await invokePlanWorkflow(promptDescription);
|
|
62
62
|
const modifiedPlanContent = await this.planModificationLoop(planContent);
|
|
@@ -5,6 +5,7 @@ import { Options } from '@anthropic-ai/claude-agent-sdk';
|
|
|
5
5
|
export declare const ADDITIONAL_INSTRUCTIONS: {
|
|
6
6
|
readonly PLAN: "\n! IMPORTANT !\n1. Use TodoWrite to track your progress through plan creation.\n\n2. Please respond with only the final version of the plan content.\n\n3. Respond in a markdown format with these metadata headers:\n\n---\ntitle: <plan-title>\ndescription: <plan-description>\ndate: <plan-date>\n---\n\n<plan-content>\n\n4. After you mark all todos as complete, you must respond with the final version of the plan.\n\n5. DO NOT write the plan to disk — the CLI will handle saving the file to the plans directory.\n\n6. DO NOT suggest any next steps, follow-up commands, or instructions for the user — the CLI will inform the user of next steps after saving.\n";
|
|
7
7
|
readonly BUILD: "\n! IMPORTANT !\n1. Use TodoWrite to track your progress through workflow implementation.\n\n2. Follow the implementation plan exactly as specified in the plan file.\n\n3. Implement all workflow files following Output.ai patterns and best practices.\n\n4. After you mark all todos as complete, provide a summary of what was implemented.\n";
|
|
8
|
+
readonly MIGRATE: "\n! IMPORTANT !\n1. Use TodoWrite to track your progress through the migration.\n\n2. Fetch the migration guide from https://docs.output.ai/migrations — do not invent migration steps from memory.\n\n3. If the specific guide URL 404s, fall back to the /migrations index and chain the guides that cover the version range.\n\n4. Confirm the planned changes with the user before editing files.\n\n5. After you mark all todos as complete, provide a summary of which files changed and whether the type check passed.\n";
|
|
8
9
|
};
|
|
9
10
|
export declare const PLAN_COMMAND_OPTIONS: Options;
|
|
10
11
|
interface ReplyToClaudeOptions {
|
|
@@ -12,13 +13,14 @@ interface ReplyToClaudeOptions {
|
|
|
12
13
|
applyAdditionalInstructions?: string;
|
|
13
14
|
}
|
|
14
15
|
export declare const BUILD_COMMAND_OPTIONS: Options;
|
|
16
|
+
export declare const MIGRATE_COMMAND_OPTIONS: Options;
|
|
15
17
|
export declare class ClaudeInvocationError extends Error {
|
|
16
18
|
cause?: Error | undefined;
|
|
17
19
|
constructor(message: string, cause?: Error | undefined);
|
|
18
20
|
}
|
|
19
21
|
export declare function replyToClaude(message: string, { anthropicOpts, applyAdditionalInstructions }?: ReplyToClaudeOptions): Promise<string>;
|
|
20
22
|
/**
|
|
21
|
-
* Invoke claude-code with /
|
|
23
|
+
* Invoke claude-code with /output-plan-workflow slash command
|
|
22
24
|
* The SDK loads custom commands from .claude/commands/ when settingSources includes 'project'.
|
|
23
25
|
* ensureOutputAISystem() scaffolds the command files to that location.
|
|
24
26
|
* @param description - Workflow description
|
|
@@ -26,7 +28,7 @@ export declare function replyToClaude(message: string, { anthropicOpts, applyAdd
|
|
|
26
28
|
*/
|
|
27
29
|
export declare function invokePlanWorkflow(description: string): Promise<string>;
|
|
28
30
|
/**
|
|
29
|
-
* Invoke claude-code with /
|
|
31
|
+
* Invoke claude-code with /output-build-workflow slash command
|
|
30
32
|
* The SDK loads custom commands from .claude/commands/ when settingSources includes 'project'.
|
|
31
33
|
* ensureOutputAISystem() scaffolds the command files to that location.
|
|
32
34
|
* @param planFilePath - Absolute path to the plan file
|
|
@@ -36,4 +38,14 @@ export declare function invokePlanWorkflow(description: string): Promise<string>
|
|
|
36
38
|
* @returns Implementation output from claude-code
|
|
37
39
|
*/
|
|
38
40
|
export declare function invokeBuildWorkflow(planFilePath: string, workflowDir: string, workflowName: string, additionalInstructions?: string): Promise<string>;
|
|
41
|
+
/**
|
|
42
|
+
* Invoke claude-code with /output-migrate slash command (registered via the output-migrate skill).
|
|
43
|
+
* The slash command fetches migration instructions from docs.output.ai —
|
|
44
|
+
* this CLI wrapper just passes through the version arguments.
|
|
45
|
+
* @param fromVersion - Current framework version (empty string = auto-detect)
|
|
46
|
+
* @param toVersion - Target version (empty string = use npm "latest")
|
|
47
|
+
* @param additionalInstructions - Optional user-supplied guidance
|
|
48
|
+
* @returns Migration summary from claude-code
|
|
49
|
+
*/
|
|
50
|
+
export declare function invokeMigrate(fromVersion: string, toVersion: string, additionalInstructions?: string): Promise<string>;
|
|
39
51
|
export {};
|
|
@@ -15,7 +15,7 @@ describe('invokePlanWorkflow - Integration Tests', () => {
|
|
|
15
15
|
const messages = [];
|
|
16
16
|
try {
|
|
17
17
|
for await (const message of query({
|
|
18
|
-
prompt: `/
|
|
18
|
+
prompt: `/output-plan-workflow ${description}`,
|
|
19
19
|
options: { maxTurns: 1 }
|
|
20
20
|
})) {
|
|
21
21
|
console.log('\nReceived message:', JSON.stringify(message, null, 2));
|
|
@@ -31,7 +31,7 @@ describe('invokePlanWorkflow - Integration Tests', () => {
|
|
|
31
31
|
// This test is just for debugging - we expect messages
|
|
32
32
|
expect(messages.length).toBeGreaterThan(0);
|
|
33
33
|
}, 60000); // 60 second timeout
|
|
34
|
-
it('should successfully invoke /
|
|
34
|
+
it('should successfully invoke /output-plan-workflow slash command and return content', async () => {
|
|
35
35
|
const description = 'Simple workflow that takes a number and doubles it';
|
|
36
36
|
const result = await invokePlanWorkflow(description);
|
|
37
37
|
console.log('\n===== PLAN RESULT =====');
|
|
@@ -38,10 +38,25 @@ date: <plan-date>
|
|
|
38
38
|
3. Implement all workflow files following Output.ai patterns and best practices.
|
|
39
39
|
|
|
40
40
|
4. After you mark all todos as complete, provide a summary of what was implemented.
|
|
41
|
+
`,
|
|
42
|
+
MIGRATE: `
|
|
43
|
+
! IMPORTANT !
|
|
44
|
+
1. Use TodoWrite to track your progress through the migration.
|
|
45
|
+
|
|
46
|
+
2. Fetch the migration guide from https://docs.output.ai/migrations — do not invent migration steps from memory.
|
|
47
|
+
|
|
48
|
+
3. If the specific guide URL 404s, fall back to the /migrations index and chain the guides that cover the version range.
|
|
49
|
+
|
|
50
|
+
4. Confirm the planned changes with the user before editing files.
|
|
51
|
+
|
|
52
|
+
5. After you mark all todos as complete, provide a summary of which files changed and whether the type check passed.
|
|
41
53
|
`
|
|
42
54
|
};
|
|
43
|
-
|
|
44
|
-
|
|
55
|
+
// Slash-command naming convention used by the outputai plugin:
|
|
56
|
+
// `outputai:<kebab-name>` — skills under coding_assistants/.../skills/, which surface as top-level slash commands without the plugin prefix.
|
|
57
|
+
const PLAN_COMMAND = 'outputai:output-plan-workflow';
|
|
58
|
+
const BUILD_COMMAND = 'outputai:output-build-workflow';
|
|
59
|
+
const MIGRATE_COMMAND = 'outputai:output-migrate';
|
|
45
60
|
const GLOBAL_CLAUDE_OPTIONS = {
|
|
46
61
|
settingSources: ['user', 'project', 'local']
|
|
47
62
|
};
|
|
@@ -51,6 +66,9 @@ export const PLAN_COMMAND_OPTIONS = {
|
|
|
51
66
|
export const BUILD_COMMAND_OPTIONS = {
|
|
52
67
|
permissionMode: 'bypassPermissions'
|
|
53
68
|
};
|
|
69
|
+
export const MIGRATE_COMMAND_OPTIONS = {
|
|
70
|
+
permissionMode: 'bypassPermissions'
|
|
71
|
+
};
|
|
54
72
|
export class ClaudeInvocationError extends Error {
|
|
55
73
|
cause;
|
|
56
74
|
constructor(message, cause) {
|
|
@@ -77,7 +95,7 @@ function validateEnvironment() {
|
|
|
77
95
|
}
|
|
78
96
|
}
|
|
79
97
|
function validateSystem(systemMessage) {
|
|
80
|
-
const requiredCommands = [PLAN_COMMAND, BUILD_COMMAND];
|
|
98
|
+
const requiredCommands = [PLAN_COMMAND, BUILD_COMMAND, MIGRATE_COMMAND];
|
|
81
99
|
const availableCommands = systemMessage.slash_commands;
|
|
82
100
|
const missingCommands = requiredCommands.filter(command => !availableCommands.includes(command));
|
|
83
101
|
return {
|
|
@@ -193,7 +211,7 @@ export async function replyToClaude(message, { anthropicOpts, applyAdditionalIns
|
|
|
193
211
|
return singleQuery(applyInstructions(message, applyAdditionalInstructions), { continue: true, ...anthropicOpts });
|
|
194
212
|
}
|
|
195
213
|
/**
|
|
196
|
-
* Invoke claude-code with /
|
|
214
|
+
* Invoke claude-code with /output-plan-workflow slash command
|
|
197
215
|
* The SDK loads custom commands from .claude/commands/ when settingSources includes 'project'.
|
|
198
216
|
* ensureOutputAISystem() scaffolds the command files to that location.
|
|
199
217
|
* @param description - Workflow description
|
|
@@ -203,7 +221,7 @@ export async function invokePlanWorkflow(description) {
|
|
|
203
221
|
return singleQuery(applyInstructions(`/${PLAN_COMMAND} ${description}`, ADDITIONAL_INSTRUCTIONS.PLAN), PLAN_COMMAND_OPTIONS);
|
|
204
222
|
}
|
|
205
223
|
/**
|
|
206
|
-
* Invoke claude-code with /
|
|
224
|
+
* Invoke claude-code with /output-build-workflow slash command
|
|
207
225
|
* The SDK loads custom commands from .claude/commands/ when settingSources includes 'project'.
|
|
208
226
|
* ensureOutputAISystem() scaffolds the command files to that location.
|
|
209
227
|
* @param planFilePath - Absolute path to the plan file
|
|
@@ -219,3 +237,18 @@ export async function invokeBuildWorkflow(planFilePath, workflowDir, workflowNam
|
|
|
219
237
|
`/${BUILD_COMMAND} ${commandArgs}`;
|
|
220
238
|
return singleQuery(applyInstructions(fullCommand, ADDITIONAL_INSTRUCTIONS.BUILD), BUILD_COMMAND_OPTIONS);
|
|
221
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* Invoke claude-code with /output-migrate slash command (registered via the output-migrate skill).
|
|
242
|
+
* The slash command fetches migration instructions from docs.output.ai —
|
|
243
|
+
* this CLI wrapper just passes through the version arguments.
|
|
244
|
+
* @param fromVersion - Current framework version (empty string = auto-detect)
|
|
245
|
+
* @param toVersion - Target version (empty string = use npm "latest")
|
|
246
|
+
* @param additionalInstructions - Optional user-supplied guidance
|
|
247
|
+
* @returns Migration summary from claude-code
|
|
248
|
+
*/
|
|
249
|
+
export async function invokeMigrate(fromVersion, toVersion, additionalInstructions) {
|
|
250
|
+
const from = fromVersion || 'auto';
|
|
251
|
+
const to = toVersion || 'latest';
|
|
252
|
+
const commandArgs = [from, to, additionalInstructions].filter(Boolean).join(' ');
|
|
253
|
+
return singleQuery(applyInstructions(`/${MIGRATE_COMMAND} ${commandArgs}`, ADDITIONAL_INSTRUCTIONS.MIGRATE), MIGRATE_COMMAND_OPTIONS);
|
|
254
|
+
}
|
|
@@ -13,7 +13,7 @@ describe('invokePlanWorkflow', () => {
|
|
|
13
13
|
// Clean up environment variables
|
|
14
14
|
delete process.env.ANTHROPIC_API_KEY;
|
|
15
15
|
});
|
|
16
|
-
it('should invoke /outputai:
|
|
16
|
+
it('should invoke /outputai:output-plan-workflow slash command with settingSources', async () => {
|
|
17
17
|
const { query } = await import('@anthropic-ai/claude-agent-sdk');
|
|
18
18
|
process.env.ANTHROPIC_API_KEY = 'test-key';
|
|
19
19
|
async function* mockIterator() {
|
|
@@ -22,7 +22,7 @@ describe('invokePlanWorkflow', () => {
|
|
|
22
22
|
vi.mocked(query).mockReturnValue(mockIterator());
|
|
23
23
|
await invokePlanWorkflow('Test workflow');
|
|
24
24
|
const calls = vi.mocked(query).mock.calls;
|
|
25
|
-
expect(calls[0]?.[0]?.prompt).toContain('/outputai:
|
|
25
|
+
expect(calls[0]?.[0]?.prompt).toContain('/outputai:output-plan-workflow Test workflow');
|
|
26
26
|
expect(calls[0]?.[0]?.options?.settingSources).toEqual(['user', 'project', 'local']);
|
|
27
27
|
expect(calls[0]?.[0]?.options?.allowedTools).toEqual(['Read', 'Grep', 'WebSearch', 'WebFetch', 'TodoWrite']);
|
|
28
28
|
});
|
|
@@ -36,7 +36,7 @@ describe('invokePlanWorkflow', () => {
|
|
|
36
36
|
const description = 'Build a user authentication system';
|
|
37
37
|
await invokePlanWorkflow(description);
|
|
38
38
|
const calls = vi.mocked(query).mock.calls;
|
|
39
|
-
expect(calls[0]?.[0]?.prompt).toContain(`/outputai:
|
|
39
|
+
expect(calls[0]?.[0]?.prompt).toContain(`/outputai:output-plan-workflow ${description}`);
|
|
40
40
|
expect(calls[0]?.[0]?.options?.settingSources).toEqual(['user', 'project', 'local']);
|
|
41
41
|
});
|
|
42
42
|
it('should return plan output from claude-code', async () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Build a workflow from a plan file using the /
|
|
2
|
+
* Build a workflow from a plan file using the /output-build-workflow slash command
|
|
3
3
|
* @param planFilePath - Absolute path to the plan file
|
|
4
4
|
* @param workflowDir - Absolute path to the workflow directory
|
|
5
5
|
* @param workflowName - Name of the workflow
|
|
@@ -31,7 +31,7 @@ function isEmpty(modification) {
|
|
|
31
31
|
return modification.trim() === '';
|
|
32
32
|
}
|
|
33
33
|
/**
|
|
34
|
-
* Build a workflow from a plan file using the /
|
|
34
|
+
* Build a workflow from a plan file using the /output-build-workflow slash command
|
|
35
35
|
* @param planFilePath - Absolute path to the plan file
|
|
36
36
|
* @param workflowDir - Absolute path to the workflow directory
|
|
37
37
|
* @param workflowName - Name of the workflow
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@outputai/cli",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1-next.af8a069.0",
|
|
4
4
|
"description": "CLI for Output.ai workflow generation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
"react": "19.2.5",
|
|
36
36
|
"semver": "7.7.4",
|
|
37
37
|
"yaml": "^2.8.3",
|
|
38
|
-
"@outputai/credentials": "0.1
|
|
39
|
-
"@outputai/evals": "0.1
|
|
40
|
-
"@outputai/llm": "0.1
|
|
38
|
+
"@outputai/credentials": "0.2.1-next.af8a069.0",
|
|
39
|
+
"@outputai/evals": "0.2.1-next.af8a069.0",
|
|
40
|
+
"@outputai/llm": "0.2.1-next.af8a069.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/cli-progress": "3.11.6",
|