@hadooppei/hwcode 0.1.0 → 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.
@@ -1,5 +1,3 @@
1
- import { homedir, tmpdir } from "node:os";
2
-
3
1
  import type {
4
2
  ExtensionAPI,
5
3
  ExtensionCommandContext,
@@ -8,33 +6,19 @@ import type {
8
6
 
9
7
  import {
10
8
  canonicalizeWorkspaceRoot,
11
- findExternalPathReferences,
12
- isPathInsideRoot,
13
- resolveToolPath,
14
9
  } from "../lib/workflow-guard.ts";
15
10
  import { getWorkingDirectory } from "../lib/working-directory.ts";
16
-
17
- const STATE_TYPE = "hwcode-workflow-state";
18
- const AUDIT_TYPE = "hwcode-workflow-external-approval";
19
- const FILE_PATH_TOOLS = new Set(["read", "write", "edit", "grep", "find", "ls"]);
20
-
21
- type WorkflowMode = "vibe" | "sdd";
22
-
23
- interface ActiveWorkflowState {
24
- version: 1;
25
- active: true;
26
- mode: WorkflowMode;
27
- root: string;
28
- activatedAt: string;
29
- }
30
-
31
- interface InactiveWorkflowState {
32
- version: 1;
33
- active: false;
34
- deactivatedAt: string;
35
- }
36
-
37
- type WorkflowState = ActiveWorkflowState | InactiveWorkflowState;
11
+ import {
12
+ WORKFLOW_EXTERNAL_AUDIT_TYPE,
13
+ WORKFLOW_STATE_TYPE,
14
+ activeWorkflow,
15
+ createWorkflowState,
16
+ updateWorkflowState,
17
+ workflowLabel,
18
+ type WorkflowMode,
19
+ type WorkflowState,
20
+ } from "../lib/workflows/state.ts";
21
+ import { evaluateToolPathAccess } from "../lib/workspace/access-policy.ts";
38
22
 
39
23
  interface GitState {
40
24
  initialized: boolean;
@@ -42,28 +26,8 @@ interface GitState {
42
26
  status: string;
43
27
  }
44
28
 
45
- function isWorkflowState(value: unknown): value is WorkflowState {
46
- if (!value || typeof value !== "object") return false;
47
- const data = value as Record<string, unknown>;
48
- if (data.version !== 1 || typeof data.active !== "boolean") return false;
49
- if (data.active) {
50
- return (data.mode === "vibe" || data.mode === "sdd")
51
- && typeof data.root === "string"
52
- && typeof data.activatedAt === "string";
53
- }
54
- return typeof data.deactivatedAt === "string";
55
- }
56
-
57
- function restoreState(ctx: ExtensionContext): WorkflowState | undefined {
58
- for (const entry of [...ctx.sessionManager.getEntries()].reverse()) {
59
- if (entry.type !== "custom" || entry.customType !== STATE_TYPE || !isWorkflowState(entry.data)) continue;
60
- return entry.data;
61
- }
62
- return undefined;
63
- }
64
-
65
29
  function modeLabel(mode: WorkflowMode): string {
66
- return mode === "vibe" ? "HWCode Vibe" : "HWCode SDD";
30
+ return workflowLabel(mode);
67
31
  }
68
32
 
69
33
  function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
@@ -155,7 +119,7 @@ function activationPrompt(mode: WorkflowMode, root: string, git: GitState | unde
155
119
  return `/skill:hwcode-${mode} ${context.join("\n\n")}`;
156
120
  }
157
121
 
158
- function workflowSystemPrompt(state: ActiveWorkflowState): string {
122
+ function workflowSystemPrompt(state: WorkflowState): string {
159
123
  const common = [
160
124
  `HWCODE WORKFLOW ACTIVE: ${state.mode.toUpperCase()}`,
161
125
  `The sole project root for this session is ${state.root}.`,
@@ -174,7 +138,7 @@ function workflowSystemPrompt(state: ActiveWorkflowState): string {
174
138
  }
175
139
 
176
140
  export default function (pi: ExtensionAPI) {
177
- let activeState: ActiveWorkflowState | undefined;
141
+ let activeState: WorkflowState | undefined;
178
142
 
179
143
  async function activate(mode: WorkflowMode, args: string, ctx: ExtensionCommandContext): Promise<void> {
180
144
  if (!ctx.isIdle()) {
@@ -183,6 +147,15 @@ export default function (pi: ExtensionAPI) {
183
147
  }
184
148
 
185
149
  const root = canonicalizeWorkspaceRoot(getWorkingDirectory(ctx.sessionManager));
150
+ const existing = activeWorkflow(ctx.sessionManager.getEntries());
151
+ if (existing) {
152
+ notify(
153
+ ctx,
154
+ `${workflowLabel(existing.mode)} is already active for ${existing.root}. Start a new session before activating another workflow.`,
155
+ "warning",
156
+ );
157
+ return;
158
+ }
186
159
  if (!(await confirmWorkspace(mode, root, ctx))) {
187
160
  notify(ctx, `${modeLabel(mode)} was not started.`, "warning");
188
161
  return;
@@ -191,14 +164,8 @@ export default function (pi: ExtensionAPI) {
191
164
  const git = mode === "sdd" ? await inspectOrInitializeGit(pi, root, ctx) : undefined;
192
165
  if (mode === "sdd" && !git) return;
193
166
 
194
- activeState = {
195
- version: 1,
196
- active: true,
197
- mode,
198
- root,
199
- activatedAt: new Date().toISOString(),
200
- };
201
- pi.appendEntry<WorkflowState>(STATE_TYPE, activeState);
167
+ activeState = createWorkflowState(mode as "vibe" | "sdd", root);
168
+ pi.appendEntry<WorkflowState>(WORKFLOW_STATE_TYPE, activeState);
202
169
  notify(ctx, `${modeLabel(mode)} activated. Project root locked to ${root}.`);
203
170
  pi.sendUserMessage(activationPrompt(mode, root, git, args.trim()), { expandPromptTemplates: true });
204
171
  }
@@ -214,8 +181,8 @@ export default function (pi: ExtensionAPI) {
214
181
  });
215
182
 
216
183
  pi.on("session_start", async (_event, ctx) => {
217
- const restored = restoreState(ctx);
218
- if (!restored?.active) {
184
+ const restored = activeWorkflow(ctx.sessionManager.getEntries());
185
+ if (!restored) {
219
186
  activeState = undefined;
220
187
  return;
221
188
  }
@@ -223,11 +190,11 @@ export default function (pi: ExtensionAPI) {
223
190
  const currentRoot = canonicalizeWorkspaceRoot(getWorkingDirectory(ctx.sessionManager));
224
191
  if (currentRoot !== restored.root) {
225
192
  activeState = undefined;
226
- pi.appendEntry<WorkflowState>(STATE_TYPE, {
227
- version: 1,
228
- active: false,
229
- deactivatedAt: new Date().toISOString(),
230
- });
193
+ pi.appendEntry<WorkflowState>(WORKFLOW_STATE_TYPE, updateWorkflowState(restored, {
194
+ status: "failed",
195
+ phase: "invalid-root",
196
+ reason: "Stored workflow root no longer matches the current execution directory.",
197
+ }));
231
198
  notify(ctx, "Stored HWCode workflow disabled because the current directory changed.", "warning");
232
199
  return;
233
200
  }
@@ -236,25 +203,18 @@ export default function (pi: ExtensionAPI) {
236
203
  notify(ctx, `${modeLabel(restored.mode)} restored. Root: ${restored.root}`);
237
204
  });
238
205
 
239
- pi.on("before_agent_start", async (event) => {
206
+ pi.on("before_agent_start", async (event, ctx) => {
207
+ activeState = activeWorkflow(ctx.sessionManager.getEntries());
240
208
  if (!activeState) return undefined;
241
209
  return { systemPrompt: `${event.systemPrompt}\n\n${workflowSystemPrompt(activeState)}` };
242
210
  });
243
211
 
244
212
  pi.on("tool_call", async (event, ctx) => {
213
+ activeState = activeWorkflow(ctx.sessionManager.getEntries());
245
214
  if (!activeState) return undefined;
246
215
 
247
216
  const input = event.input as Record<string, unknown>;
248
- let external: Array<{ raw: string; resolved: string }> = [];
249
- if (FILE_PATH_TOOLS.has(event.toolName) && typeof input.path === "string") {
250
- const resolved = resolveToolPath(activeState.root, input.path, homedir());
251
- if (!isPathInsideRoot(activeState.root, resolved)) external = [{ raw: input.path, resolved }];
252
- } else if (event.toolName === "bash" && typeof input.command === "string") {
253
- external = findExternalPathReferences(input.command, activeState.root, {
254
- home: homedir(),
255
- tmpdir: tmpdir(),
256
- });
257
- }
217
+ const external = evaluateToolPathAccess({ toolName: event.toolName, input }, activeState.root);
258
218
 
259
219
  if (external.length === 0) return undefined;
260
220
  const details = external.map((entry) => `• ${entry.raw} → ${entry.resolved}`).join("\n");
@@ -280,7 +240,7 @@ export default function (pi: ExtensionAPI) {
280
240
  return { block: true, reason: `User denied external path access:\n${details}` };
281
241
  }
282
242
 
283
- pi.appendEntry(AUDIT_TYPE, {
243
+ pi.appendEntry(WORKFLOW_EXTERNAL_AUDIT_TYPE, {
284
244
  mode: activeState.mode,
285
245
  root: activeState.root,
286
246
  toolName: event.toolName,
@@ -289,4 +249,8 @@ export default function (pi: ExtensionAPI) {
289
249
  });
290
250
  return undefined;
291
251
  });
252
+
253
+ pi.on("session_shutdown", () => {
254
+ activeState = undefined;
255
+ });
292
256
  }
@@ -0,0 +1,234 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import {
5
+ cloudEnvironment,
6
+ getCloudProvider,
7
+ redactCredentialValues,
8
+ type CloudCredentials,
9
+ type CloudProvider,
10
+ type CloudVendorId,
11
+ } from "../cloud-providers.ts";
12
+ import { runProcess, truncateOutput, type ProcessResult } from "./process.ts";
13
+
14
+ export interface TemporaryCredentialStore {
15
+ createDirectory(prefix: string): string;
16
+ cleanupDirectory(directory: string): void;
17
+ }
18
+
19
+ export interface AdapterContext {
20
+ root: string;
21
+ credentials: CloudCredentials;
22
+ temporaryStore: TemporaryCredentialStore;
23
+ signal?: AbortSignal;
24
+ }
25
+
26
+ export interface PreparedCloudExecution {
27
+ args: string[];
28
+ env: Record<string, string>;
29
+ cleanup?: () => void;
30
+ error?: string;
31
+ }
32
+
33
+ export interface CloudProviderAdapter {
34
+ provider: CloudProvider;
35
+ validate(context: AdapterContext): Promise<ProcessResult>;
36
+ prepare(command: string, args: string[], context: AdapterContext): Promise<PreparedCloudExecution>;
37
+ }
38
+
39
+ function validationFailure(result: ProcessResult, credentials: CloudCredentials): string {
40
+ const raw = result.stderr.trim() || result.stdout.trim() || `command exited with code ${result.code}`;
41
+ return truncateOutput(redactCredentialValues(raw, credentials));
42
+ }
43
+
44
+ function huaweiAuthenticationArgs(credentials: CloudCredentials): string[] {
45
+ return [
46
+ `--cli-access-key=${credentials.accessKey}`,
47
+ `--cli-secret-key=${credentials.secretKey}`,
48
+ `--cli-region=${credentials.region}`,
49
+ ...(credentials.securityToken ? [`--cli-security-token=${credentials.securityToken}`] : []),
50
+ ...(credentials.projectId ? [`--cli-project-id=${credentials.projectId}`] : []),
51
+ ...(credentials.domainId ? [`--cli-domain-id=${credentials.domainId}`] : []),
52
+ "--cli-output=json",
53
+ ];
54
+ }
55
+
56
+ function basicAdapter(
57
+ vendor: CloudVendorId,
58
+ validationCommand: string,
59
+ validationArgs: string[],
60
+ ): CloudProviderAdapter {
61
+ return {
62
+ provider: getCloudProvider(vendor),
63
+ validate: ({ root, credentials, signal }) => runProcess(validationCommand, validationArgs, {
64
+ cwd: root,
65
+ env: cloudEnvironment(vendor, credentials),
66
+ signal,
67
+ timeoutMs: 30_000,
68
+ }),
69
+ prepare: async (_command, args, { credentials }) => ({
70
+ args,
71
+ env: cloudEnvironment(vendor, credentials),
72
+ }),
73
+ };
74
+ }
75
+
76
+ async function validateAzure(context: AdapterContext): Promise<ProcessResult> {
77
+ const controller = new AbortController();
78
+ const timeout = setTimeout(() => controller.abort(), 30_000);
79
+ const abort = () => controller.abort();
80
+ context.signal?.addEventListener("abort", abort, { once: true });
81
+ try {
82
+ const form = new URLSearchParams({
83
+ client_id: context.credentials.clientId,
84
+ client_secret: context.credentials.clientSecret,
85
+ grant_type: "client_credentials",
86
+ scope: "https://management.azure.com/.default",
87
+ });
88
+ const tokenResponse = await fetch(
89
+ `https://login.microsoftonline.com/${encodeURIComponent(context.credentials.tenantId)}/oauth2/v2.0/token`,
90
+ {
91
+ method: "POST",
92
+ headers: { "content-type": "application/x-www-form-urlencoded" },
93
+ body: form,
94
+ signal: controller.signal,
95
+ },
96
+ );
97
+ const tokenBody = await tokenResponse.json() as Record<string, unknown>;
98
+ if (!tokenResponse.ok || typeof tokenBody.access_token !== "string") {
99
+ return { stdout: "", stderr: `Azure token request failed (${tokenResponse.status}).`, code: 1, killed: false };
100
+ }
101
+ const subscriptionResponse = await fetch(
102
+ `https://management.azure.com/subscriptions/${encodeURIComponent(context.credentials.subscriptionId)}?api-version=2022-12-01`,
103
+ { headers: { authorization: `Bearer ${tokenBody.access_token}` }, signal: controller.signal },
104
+ );
105
+ if (!subscriptionResponse.ok) {
106
+ return { stdout: "", stderr: `Azure subscription check failed (${subscriptionResponse.status}).`, code: 1, killed: false };
107
+ }
108
+ const subscription = await subscriptionResponse.json() as Record<string, unknown>;
109
+ return {
110
+ stdout: JSON.stringify({ subscriptionId: subscription.subscriptionId, displayName: subscription.displayName }),
111
+ stderr: "",
112
+ code: 0,
113
+ killed: false,
114
+ };
115
+ } catch (error) {
116
+ return { stdout: "", stderr: error instanceof Error ? error.message : String(error), code: 1, killed: false };
117
+ } finally {
118
+ clearTimeout(timeout);
119
+ context.signal?.removeEventListener("abort", abort);
120
+ }
121
+ }
122
+
123
+ const adapters: Record<CloudVendorId, CloudProviderAdapter> = {
124
+ aws: basicAdapter("aws", "aws", ["sts", "get-caller-identity", "--output", "json"]),
125
+ alibaba: basicAdapter("alibaba", "aliyun", ["sts", "get-caller-identity"]),
126
+ tencent: basicAdapter("tencent", "tccli", ["sts", "GetCallerIdentity", "--output", "json"]),
127
+ huawei: {
128
+ provider: getCloudProvider("huawei"),
129
+ validate: ({ root, credentials, signal }) => runProcess(
130
+ "hcloud",
131
+ ["IAM", "KeystoneListProjects", ...huaweiAuthenticationArgs(credentials)],
132
+ { cwd: root, env: cloudEnvironment("huawei", credentials), signal, timeoutMs: 30_000 },
133
+ ),
134
+ prepare: async (command, args, { credentials }) => ({
135
+ args: command === "hcloud" ? [...args, ...huaweiAuthenticationArgs(credentials)] : args,
136
+ env: cloudEnvironment("huawei", credentials),
137
+ }),
138
+ },
139
+ gcp: {
140
+ provider: getCloudProvider("gcp"),
141
+ validate: async (context) => {
142
+ const directory = context.temporaryStore.createDirectory("hwcode-cloud-gcp-");
143
+ const path = join(directory, "credential.json");
144
+ writeFileSync(path, context.credentials.serviceAccountJson, { encoding: "utf8", mode: 0o600 });
145
+ try {
146
+ return await runProcess("gcloud", ["projects", "describe", context.credentials.projectId, "--format=json"], {
147
+ cwd: context.root,
148
+ env: {
149
+ ...cloudEnvironment("gcp", context.credentials),
150
+ GOOGLE_APPLICATION_CREDENTIALS: path,
151
+ CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE: path,
152
+ },
153
+ signal: context.signal,
154
+ timeoutMs: 30_000,
155
+ });
156
+ } finally {
157
+ context.temporaryStore.cleanupDirectory(directory);
158
+ }
159
+ },
160
+ prepare: async (_command, args, context) => {
161
+ const directory = context.temporaryStore.createDirectory("hwcode-cloud-gcp-");
162
+ const path = join(directory, "credential.json");
163
+ writeFileSync(path, context.credentials.serviceAccountJson, { encoding: "utf8", mode: 0o600 });
164
+ return {
165
+ args,
166
+ env: {
167
+ ...cloudEnvironment("gcp", context.credentials),
168
+ GOOGLE_APPLICATION_CREDENTIALS: path,
169
+ CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE: path,
170
+ },
171
+ cleanup: () => context.temporaryStore.cleanupDirectory(directory),
172
+ };
173
+ },
174
+ },
175
+ azure: {
176
+ provider: getCloudProvider("azure"),
177
+ validate: validateAzure,
178
+ prepare: async (command, args, context) => {
179
+ const env = cloudEnvironment("azure", context.credentials);
180
+ if (command !== "az") return { args, env };
181
+ const directory = context.temporaryStore.createDirectory("hwcode-cloud-azure-");
182
+ const cleanup = () => context.temporaryStore.cleanupDirectory(directory);
183
+ const azureEnv = { ...env, AZURE_CONFIG_DIR: directory };
184
+ const login = await runProcess("az", [
185
+ "login", "--service-principal",
186
+ "--username", context.credentials.clientId,
187
+ "--password", context.credentials.clientSecret,
188
+ "--tenant", context.credentials.tenantId,
189
+ "--output", "none",
190
+ ], { cwd: context.root, env: azureEnv, signal: context.signal, timeoutMs: 60_000 });
191
+ if (login.code !== 0) {
192
+ return { args, env: azureEnv, cleanup, error: validationFailure(login, context.credentials) };
193
+ }
194
+ const selection = await runProcess(
195
+ "az",
196
+ ["account", "set", "--subscription", context.credentials.subscriptionId],
197
+ { cwd: context.root, env: azureEnv, signal: context.signal, timeoutMs: 30_000 },
198
+ );
199
+ if (selection.code !== 0) {
200
+ return { args, env: azureEnv, cleanup, error: validationFailure(selection, context.credentials) };
201
+ }
202
+ return { args, env: azureEnv, cleanup };
203
+ },
204
+ },
205
+ };
206
+
207
+ export function getCloudAdapter(vendor: CloudVendorId): CloudProviderAdapter {
208
+ return adapters[vendor];
209
+ }
210
+
211
+ export function validateCloudCredentials(
212
+ vendor: CloudVendorId,
213
+ context: AdapterContext,
214
+ ): Promise<ProcessResult> {
215
+ return getCloudAdapter(vendor).validate(context);
216
+ }
217
+
218
+ export function prepareCloudExecution(
219
+ vendor: CloudVendorId,
220
+ command: string,
221
+ args: string[],
222
+ context: AdapterContext,
223
+ ): Promise<PreparedCloudExecution> {
224
+ return getCloudAdapter(vendor).prepare(command, args, context);
225
+ }
226
+
227
+ export function formatValidationFailure(result: ProcessResult, credentials: CloudCredentials): string {
228
+ return validationFailure(result, credentials);
229
+ }
230
+
231
+ export function checkProviderCli(vendor: CloudVendorId, root: string): Promise<ProcessResult> {
232
+ const provider = getCloudProvider(vendor);
233
+ return runProcess(provider.cli, provider.versionArgs, { cwd: root, timeoutMs: 10_000 });
234
+ }
@@ -0,0 +1,91 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ export const MAX_CLOUD_OUTPUT_BYTES = 50_000;
4
+ const MAX_CAPTURE_BYTES = MAX_CLOUD_OUTPUT_BYTES * 2;
5
+ const CLOUD_ENV_PREFIXES = [
6
+ "AWS_", "AZURE_", "ARM_", "GOOGLE_", "CLOUDSDK_", "HW_", "HUAWEI",
7
+ "ALIBABA_CLOUD_", "TENCENTCLOUD_",
8
+ ];
9
+
10
+ export interface ProcessResult {
11
+ stdout: string;
12
+ stderr: string;
13
+ code: number;
14
+ killed: boolean;
15
+ timedOut?: boolean;
16
+ spawnErrorCode?: string;
17
+ }
18
+
19
+ function captureOutput(current: string, chunk: Buffer): string {
20
+ if (Buffer.byteLength(current, "utf8") >= MAX_CAPTURE_BYTES) return current;
21
+ const remaining = MAX_CAPTURE_BYTES - Buffer.byteLength(current, "utf8");
22
+ return `${current}${chunk.subarray(0, remaining).toString("utf8")}`;
23
+ }
24
+
25
+ export function isolatedCloudEnvironment(extra: Record<string, string> = {}): NodeJS.ProcessEnv {
26
+ const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => (
27
+ !CLOUD_ENV_PREFIXES.some((prefix) => key.startsWith(prefix))
28
+ )));
29
+ return { ...inherited, ...extra };
30
+ }
31
+
32
+ export function runProcess(
33
+ command: string,
34
+ args: readonly string[],
35
+ options: {
36
+ cwd: string;
37
+ env?: Record<string, string>;
38
+ signal?: AbortSignal;
39
+ timeoutMs?: number;
40
+ },
41
+ ): Promise<ProcessResult> {
42
+ return new Promise((finish) => {
43
+ let stdout = "";
44
+ let stderr = "";
45
+ let killed = false;
46
+ let timedOut = false;
47
+ let settled = false;
48
+ let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
49
+ const child = spawn(command, [...args], {
50
+ cwd: options.cwd,
51
+ env: isolatedCloudEnvironment(options.env),
52
+ stdio: ["ignore", "pipe", "pipe"],
53
+ shell: false,
54
+ });
55
+ const complete = (result: ProcessResult) => {
56
+ if (settled) return;
57
+ settled = true;
58
+ clearTimeout(timeoutTimer);
59
+ if (forceKillTimer) clearTimeout(forceKillTimer);
60
+ options.signal?.removeEventListener("abort", abort);
61
+ finish(result);
62
+ };
63
+ const terminate = (timeout: boolean) => {
64
+ killed = true;
65
+ timedOut ||= timeout;
66
+ child.kill("SIGTERM");
67
+ forceKillTimer ??= setTimeout(() => child.kill("SIGKILL"), 2_000);
68
+ };
69
+ const abort = () => terminate(false);
70
+ const timeoutTimer = setTimeout(() => terminate(true), options.timeoutMs ?? 120_000);
71
+ if (options.signal?.aborted) abort();
72
+ else options.signal?.addEventListener("abort", abort, { once: true });
73
+ child.stdout.on("data", (chunk: Buffer) => { stdout = captureOutput(stdout, chunk); });
74
+ child.stderr.on("data", (chunk: Buffer) => { stderr = captureOutput(stderr, chunk); });
75
+ child.on("error", (error) => complete({
76
+ stdout,
77
+ stderr: `${stderr}${error.message}`,
78
+ code: 127,
79
+ killed,
80
+ timedOut,
81
+ spawnErrorCode: (error as NodeJS.ErrnoException).code,
82
+ }));
83
+ child.on("close", (code) => complete({ stdout, stderr, code: code ?? 1, killed, timedOut }));
84
+ });
85
+ }
86
+
87
+ export function truncateOutput(text: string): string {
88
+ const bytes = Buffer.from(text, "utf8");
89
+ if (bytes.length <= MAX_CLOUD_OUTPUT_BYTES) return text;
90
+ return `${bytes.subarray(0, MAX_CLOUD_OUTPUT_BYTES).toString("utf8")}\n\n[HWCode truncated cloud command output]`;
91
+ }
@@ -0,0 +1,148 @@
1
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, join } from "node:path";
4
+
5
+ import { getCloudProvider, isCloudVendorId, type CloudVendorId } from "../cloud-providers.ts";
6
+ import type { WorkflowState } from "../workflows/state.ts";
7
+
8
+ export interface CloudPromptTemplate {
9
+ id: string;
10
+ description: string;
11
+ vendor: CloudVendorId;
12
+ deployCurrentProject: boolean;
13
+ objective: string;
14
+ body: string;
15
+ path: string;
16
+ }
17
+
18
+ export function defaultCloudPromptDirectory(home = homedir()): string {
19
+ return join(home, ".hwcode", "cloud", "prompts");
20
+ }
21
+
22
+ function slugify(value: string): string {
23
+ const slug = value
24
+ .normalize("NFKD")
25
+ .toLowerCase()
26
+ .replace(/[^a-z0-9]+/gu, "-")
27
+ .replace(/^-+|-+$/gu, "")
28
+ .slice(0, 48);
29
+ return slug || `task-${new Date().toISOString().slice(0, 10)}`;
30
+ }
31
+
32
+ function frontmatterValue(text: string, key: string): string | undefined {
33
+ const line = text.match(new RegExp(`^${key}:\\s*(.+)$`, "mu"))?.[1]?.trim();
34
+ if (!line) return undefined;
35
+ try {
36
+ const value = JSON.parse(line) as unknown;
37
+ return typeof value === "string" ? value : String(value);
38
+ } catch {
39
+ return line.replace(/^['"]|['"]$/gu, "");
40
+ }
41
+ }
42
+
43
+ export function parseCloudPromptTemplate(path: string): CloudPromptTemplate | undefined {
44
+ const text = readFileSync(path, "utf8");
45
+ const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/u);
46
+ if (!match) return undefined;
47
+ const metadata = match[1];
48
+ const vendorValue = frontmatterValue(metadata, "hwcode-cloud-vendor");
49
+ if (!vendorValue || !isCloudVendorId(vendorValue)) return undefined;
50
+ const id = basename(path, ".md");
51
+ return {
52
+ id,
53
+ description: frontmatterValue(metadata, "description") ?? id,
54
+ vendor: vendorValue,
55
+ deployCurrentProject: frontmatterValue(metadata, "hwcode-cloud-deploy") === "true",
56
+ objective: frontmatterValue(metadata, "hwcode-cloud-objective") ?? "",
57
+ body: match[2].trim(),
58
+ path,
59
+ };
60
+ }
61
+
62
+ export function listCloudPromptTemplates(
63
+ directory = defaultCloudPromptDirectory(),
64
+ ): CloudPromptTemplate[] {
65
+ if (!existsSync(directory)) return [];
66
+ return readdirSync(directory, { withFileTypes: true })
67
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
68
+ .map((entry) => parseCloudPromptTemplate(join(directory, entry.name)))
69
+ .filter((template): template is CloudPromptTemplate => Boolean(template))
70
+ .sort((left, right) => left.description.localeCompare(right.description));
71
+ }
72
+
73
+ export function expandCloudPromptTemplate(template: CloudPromptTemplate, argumentsText: string): string {
74
+ const value = argumentsText.trim();
75
+ return template.body.replace(/\$\{@:-([^}]*)\}/gu, (_match, fallback: string) => value || fallback);
76
+ }
77
+
78
+ function inlineCode(value: string): string {
79
+ return value.replace(/[\r\n]+/gu, " ").replaceAll("`", "\\`");
80
+ }
81
+
82
+ export function renderCloudPromptTemplate(state: WorkflowState, notes = ""): string {
83
+ if (state.mode !== "cloud" || !state.details) throw new Error("Cloud workflow state is required");
84
+ const details = state.details;
85
+ if (details.successfulSteps.length === 0) {
86
+ throw new Error("No successful Cloud execution steps are available to summarize");
87
+ }
88
+ const provider = getCloudProvider(details.vendor);
89
+ const steps = details.successfulSteps.map((step, index) => (
90
+ `${index + 1}. [${step.operation}] ${step.intent}\n`
91
+ + ` - Approach: ${step.approach}\n`
92
+ + ` - Reference command: \`${inlineCode([step.command, ...step.args].join(" "))}\``
93
+ )).join("\n");
94
+ return [
95
+ "Execute a reusable HWCode Cloud task based on a previously successful run.",
96
+ "",
97
+ `Preferred provider: ${provider.label}`,
98
+ `Deploy current project: ${details.deployCurrentProject ? "yes" : "no"}`,
99
+ `Objective pattern: ${details.request}`,
100
+ "",
101
+ "Known successful execution sequence (reference only; re-check current account state before reuse):",
102
+ steps,
103
+ "",
104
+ ...(notes ? ["Validated-run notes and pitfalls:", notes, ""] : []),
105
+ "Start with read-only discovery and adapt account, region, project, resource names, versions, and paths to the current environment. Present the plan and obtain all normal HWCode Cloud approvals. Never copy credentials or assume resources from the previous run still exist.",
106
+ "If HWCode Cloud is not active, do not run cloud commands directly; ask the user to start this template through /hwcode-cloud-template.",
107
+ "",
108
+ "Additional instructions: ${@:-Use the objective and successful sequence above.}",
109
+ ].join("\n");
110
+ }
111
+
112
+ export function saveCloudPromptTemplate(
113
+ requestedName: string,
114
+ state: WorkflowState,
115
+ directory = defaultCloudPromptDirectory(),
116
+ notes = "",
117
+ ): CloudPromptTemplate {
118
+ if (state.mode !== "cloud" || !state.details) throw new Error("Cloud workflow state is required");
119
+ const idBase = `hwcloud-${slugify(requestedName || state.details.request)}`;
120
+ const body = renderCloudPromptTemplate(state, notes);
121
+ const description = `Reuse successful ${getCloudProvider(state.details.vendor).label} workflow: ${state.details.request.slice(0, 80)}`;
122
+ const content = [
123
+ "---",
124
+ `description: ${JSON.stringify(description)}`,
125
+ 'argument-hint: "[additional instructions]"',
126
+ `hwcode-cloud-vendor: ${JSON.stringify(state.details.vendor)}`,
127
+ `hwcode-cloud-deploy: ${state.details.deployCurrentProject}`,
128
+ `hwcode-cloud-objective: ${JSON.stringify(state.details.request)}`,
129
+ "---",
130
+ body,
131
+ "",
132
+ ].join("\n");
133
+
134
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
135
+ chmodSync(directory, 0o700);
136
+ let id = idBase;
137
+ let path = join(directory, `${id}.md`);
138
+ for (let suffix = 2; existsSync(path); suffix++) {
139
+ id = `${idBase}-${suffix}`;
140
+ path = join(directory, `${id}.md`);
141
+ }
142
+ const temporaryPath = `${path}.${process.pid}.tmp`;
143
+ writeFileSync(temporaryPath, content, { encoding: "utf8", mode: 0o600 });
144
+ chmodSync(temporaryPath, 0o600);
145
+ renameSync(temporaryPath, path);
146
+ chmodSync(path, 0o600);
147
+ return parseCloudPromptTemplate(path)!;
148
+ }