clawspec 1.0.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/README.md +908 -0
- package/README.zh-CN.md +914 -0
- package/index.ts +3 -0
- package/openclaw.plugin.json +129 -0
- package/package.json +52 -0
- package/skills/openspec-apply-change.md +146 -0
- package/skills/openspec-explore.md +75 -0
- package/skills/openspec-propose.md +102 -0
- package/src/acp/client.ts +693 -0
- package/src/config.ts +220 -0
- package/src/control/keywords.ts +72 -0
- package/src/dependencies/acpx.ts +221 -0
- package/src/dependencies/openspec.ts +148 -0
- package/src/execution/session.ts +56 -0
- package/src/execution/state.ts +125 -0
- package/src/index.ts +179 -0
- package/src/memory/store.ts +118 -0
- package/src/openspec/cli.ts +279 -0
- package/src/openspec/tasks.ts +40 -0
- package/src/orchestrator/helpers.ts +312 -0
- package/src/orchestrator/service.ts +2971 -0
- package/src/planning/journal.ts +118 -0
- package/src/rollback/store.ts +173 -0
- package/src/state/locks.ts +133 -0
- package/src/state/store.ts +527 -0
- package/src/types.ts +301 -0
- package/src/utils/args.ts +88 -0
- package/src/utils/channel-key.ts +66 -0
- package/src/utils/env-path.ts +31 -0
- package/src/utils/fs.ts +218 -0
- package/src/utils/markdown.ts +136 -0
- package/src/utils/messages.ts +5 -0
- package/src/utils/paths.ts +127 -0
- package/src/utils/shell-command.ts +227 -0
- package/src/utils/slug.ts +50 -0
- package/src/watchers/manager.ts +3042 -0
- package/src/watchers/notifier.ts +69 -0
- package/src/worker/prompts.ts +484 -0
- package/src/worker/skills.ts +52 -0
- package/src/workspace/store.ts +140 -0
- package/test/acp-client.test.ts +234 -0
- package/test/acpx-dependency.test.ts +112 -0
- package/test/assistant-journal.test.ts +136 -0
- package/test/command-surface.test.ts +23 -0
- package/test/config.test.ts +77 -0
- package/test/detach-attach.test.ts +98 -0
- package/test/file-lock.test.ts +78 -0
- package/test/fs-utils.test.ts +22 -0
- package/test/helpers/harness.ts +241 -0
- package/test/helpers.test.ts +108 -0
- package/test/keywords.test.ts +80 -0
- package/test/notifier.test.ts +29 -0
- package/test/openspec-dependency.test.ts +67 -0
- package/test/pause-cancel.test.ts +55 -0
- package/test/planning-journal.test.ts +69 -0
- package/test/plugin-registration.test.ts +35 -0
- package/test/project-memory.test.ts +42 -0
- package/test/proposal.test.ts +24 -0
- package/test/queue-planning.test.ts +247 -0
- package/test/queue-work.test.ts +110 -0
- package/test/recovery.test.ts +576 -0
- package/test/service-archive.test.ts +82 -0
- package/test/shell-command.test.ts +48 -0
- package/test/state-store.test.ts +74 -0
- package/test/tasks-and-checkpoint.test.ts +60 -0
- package/test/use-project.test.ts +19 -0
- package/test/watcher-planning.test.ts +504 -0
- package/test/watcher-work.test.ts +1741 -0
- package/test/worker-command.test.ts +66 -0
- package/test/worker-skills.test.ts +12 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExecutionControlFile,
|
|
3
|
+
ExecutionResult,
|
|
4
|
+
ExecutionResultStatus,
|
|
5
|
+
ExecutionState,
|
|
6
|
+
} from "../types.ts";
|
|
7
|
+
import { pathExists, readJsonFile } from "../utils/fs.ts";
|
|
8
|
+
|
|
9
|
+
export async function readExecutionControl(filePath: string): Promise<ExecutionControlFile | null> {
|
|
10
|
+
if (!(await pathExists(filePath))) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
return normalizeExecutionControl(await readJsonFile<unknown>(filePath, null));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function readExecutionResult(filePath: string): Promise<ExecutionResult | null> {
|
|
17
|
+
if (!(await pathExists(filePath))) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
return normalizeExecutionResult(await readJsonFile<unknown>(filePath, null));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isExecutionTriggerText(text: string): boolean {
|
|
24
|
+
return /^(continue|go|start|run|proceed|ok|okay)$/i.test(text.trim());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizeExecutionControl(value: unknown): ExecutionControlFile | null {
|
|
28
|
+
if (!value || typeof value !== "object") {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const maybe = value as Record<string, unknown>;
|
|
32
|
+
if (
|
|
33
|
+
maybe.version !== 1 ||
|
|
34
|
+
typeof maybe.changeName !== "string" ||
|
|
35
|
+
!isExecutionState(maybe.state) ||
|
|
36
|
+
!isExecutionMode(maybe.mode) ||
|
|
37
|
+
typeof maybe.armedAt !== "string" ||
|
|
38
|
+
typeof maybe.pauseRequested !== "boolean" ||
|
|
39
|
+
typeof maybe.cancelRequested !== "boolean"
|
|
40
|
+
) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
version: 1,
|
|
46
|
+
changeName: maybe.changeName,
|
|
47
|
+
mode: maybe.mode,
|
|
48
|
+
state: maybe.state,
|
|
49
|
+
armedAt: maybe.armedAt,
|
|
50
|
+
startedAt: typeof maybe.startedAt === "string" ? maybe.startedAt : undefined,
|
|
51
|
+
sessionKey: typeof maybe.sessionKey === "string" ? maybe.sessionKey : undefined,
|
|
52
|
+
pauseRequested: maybe.pauseRequested,
|
|
53
|
+
cancelRequested: maybe.cancelRequested,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizeExecutionResult(value: unknown): ExecutionResult | null {
|
|
58
|
+
if (!value || typeof value !== "object") {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const maybe = value as Record<string, unknown>;
|
|
62
|
+
if (
|
|
63
|
+
maybe.version !== 1 ||
|
|
64
|
+
typeof maybe.changeName !== "string" ||
|
|
65
|
+
!isExecutionMode(maybe.mode) ||
|
|
66
|
+
!isExecutionResultStatus(maybe.status) ||
|
|
67
|
+
typeof maybe.timestamp !== "string" ||
|
|
68
|
+
typeof maybe.summary !== "string" ||
|
|
69
|
+
typeof maybe.progressMade !== "boolean"
|
|
70
|
+
) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
version: 1,
|
|
76
|
+
changeName: maybe.changeName,
|
|
77
|
+
mode: maybe.mode,
|
|
78
|
+
status: maybe.status,
|
|
79
|
+
timestamp: maybe.timestamp,
|
|
80
|
+
summary: maybe.summary,
|
|
81
|
+
progressMade: maybe.progressMade,
|
|
82
|
+
completedTask: typeof maybe.completedTask === "string" ? maybe.completedTask : undefined,
|
|
83
|
+
currentArtifact: typeof maybe.currentArtifact === "string" ? maybe.currentArtifact : undefined,
|
|
84
|
+
changedFiles: Array.isArray(maybe.changedFiles)
|
|
85
|
+
? maybe.changedFiles.filter((entry): entry is string => typeof entry === "string")
|
|
86
|
+
: [],
|
|
87
|
+
notes: Array.isArray(maybe.notes)
|
|
88
|
+
? maybe.notes.filter((entry): entry is string => typeof entry === "string")
|
|
89
|
+
: [],
|
|
90
|
+
blocker: typeof maybe.blocker === "string" ? maybe.blocker : undefined,
|
|
91
|
+
taskCounts: normalizeTaskCounts(maybe.taskCounts),
|
|
92
|
+
remainingTasks: typeof maybe.remainingTasks === "number" ? Math.trunc(maybe.remainingTasks) : undefined,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function normalizeTaskCounts(value: unknown): ExecutionResult["taskCounts"] {
|
|
97
|
+
if (!value || typeof value !== "object") {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
const maybe = value as Record<string, unknown>;
|
|
101
|
+
if (
|
|
102
|
+
typeof maybe.total !== "number" ||
|
|
103
|
+
typeof maybe.complete !== "number" ||
|
|
104
|
+
typeof maybe.remaining !== "number"
|
|
105
|
+
) {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
total: Math.trunc(maybe.total),
|
|
110
|
+
complete: Math.trunc(maybe.complete),
|
|
111
|
+
remaining: Math.trunc(maybe.remaining),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isExecutionState(value: unknown): value is ExecutionState {
|
|
116
|
+
return value === "armed" || value === "running";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isExecutionMode(value: unknown): value is ExecutionControlFile["mode"] {
|
|
120
|
+
return value === "apply" || value === "continue";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isExecutionResultStatus(value: unknown): value is ExecutionResultStatus {
|
|
124
|
+
return value === "running" || value === "paused" || value === "blocked" || value === "done" || value === "cancelled";
|
|
125
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
4
|
+
import { clawspecPluginConfigSchema, parsePluginConfig } from "./config.ts";
|
|
5
|
+
import { ProjectMemoryStore } from "./memory/store.ts";
|
|
6
|
+
import { OpenSpecClient } from "./openspec/cli.ts";
|
|
7
|
+
import { ClawSpecService } from "./orchestrator/service.ts";
|
|
8
|
+
import { ProjectStateStore } from "./state/store.ts";
|
|
9
|
+
import { ensureDir } from "./utils/fs.ts";
|
|
10
|
+
import {
|
|
11
|
+
getPluginStateRoot,
|
|
12
|
+
getProjectMemoryFilePath,
|
|
13
|
+
getWorkspaceStateFilePath,
|
|
14
|
+
} from "./utils/paths.ts";
|
|
15
|
+
import { WorkspaceStore } from "./workspace/store.ts";
|
|
16
|
+
import { AcpWorkerClient } from "./acp/client.ts";
|
|
17
|
+
import { ClawSpecNotifier } from "./watchers/notifier.ts";
|
|
18
|
+
import { WatcherManager } from "./watchers/manager.ts";
|
|
19
|
+
import { ensureOpenSpecCli } from "./dependencies/openspec.ts";
|
|
20
|
+
import { ensureAcpxCli } from "./dependencies/acpx.ts";
|
|
21
|
+
|
|
22
|
+
const PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
23
|
+
const LOCAL_BIN_DIR = path.join(PLUGIN_ROOT, "node_modules", ".bin");
|
|
24
|
+
|
|
25
|
+
const plugin = {
|
|
26
|
+
id: "clawspec",
|
|
27
|
+
name: "ClawSpec",
|
|
28
|
+
description: "OpenSpec-aware project orchestration for OpenClaw channels",
|
|
29
|
+
configSchema: clawspecPluginConfigSchema,
|
|
30
|
+
|
|
31
|
+
register(api: OpenClawPluginApi) {
|
|
32
|
+
const config = parsePluginConfig(api.pluginConfig);
|
|
33
|
+
if (!config.enabled) {
|
|
34
|
+
api.logger.info("[clawspec] Plugin disabled by configuration.");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const stateDir = api.runtime.state.resolveStateDir();
|
|
39
|
+
const pluginStateRoot = getPluginStateRoot(stateDir);
|
|
40
|
+
const stateStore = new ProjectStateStore(stateDir, config.archiveDirName);
|
|
41
|
+
const memoryStore = new ProjectMemoryStore(getProjectMemoryFilePath(stateDir));
|
|
42
|
+
const workspaceStore = new WorkspaceStore(getWorkspaceStateFilePath(stateDir), config.defaultWorkspace);
|
|
43
|
+
const openSpec = new OpenSpecClient({
|
|
44
|
+
timeoutMs: config.openSpecTimeoutMs,
|
|
45
|
+
extraPathEntries: [LOCAL_BIN_DIR],
|
|
46
|
+
});
|
|
47
|
+
const notifier = new ClawSpecNotifier({
|
|
48
|
+
api,
|
|
49
|
+
logger: api.logger,
|
|
50
|
+
});
|
|
51
|
+
let watcherManager: WatcherManager | undefined;
|
|
52
|
+
let service: ClawSpecService | undefined;
|
|
53
|
+
|
|
54
|
+
const initStores = () => Promise.all([
|
|
55
|
+
stateStore.initialize(),
|
|
56
|
+
memoryStore.initialize(),
|
|
57
|
+
workspaceStore.initialize(),
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
api.registerService({
|
|
61
|
+
id: "clawspec.bootstrap",
|
|
62
|
+
async start() {
|
|
63
|
+
await ensureDir(pluginStateRoot);
|
|
64
|
+
await ensureDir(config.defaultWorkspace);
|
|
65
|
+
await initStores();
|
|
66
|
+
await ensureOpenSpecCli({
|
|
67
|
+
pluginRoot: PLUGIN_ROOT,
|
|
68
|
+
logger: api.logger,
|
|
69
|
+
});
|
|
70
|
+
const acpx = await ensureAcpxCli({
|
|
71
|
+
pluginRoot: PLUGIN_ROOT,
|
|
72
|
+
logger: api.logger,
|
|
73
|
+
});
|
|
74
|
+
const acpClient = new AcpWorkerClient({
|
|
75
|
+
agentId: config.workerAgentId,
|
|
76
|
+
logger: api.logger,
|
|
77
|
+
command: acpx.command,
|
|
78
|
+
env: acpx.env,
|
|
79
|
+
});
|
|
80
|
+
watcherManager = new WatcherManager({
|
|
81
|
+
stateStore,
|
|
82
|
+
openSpec,
|
|
83
|
+
archiveDirName: config.archiveDirName,
|
|
84
|
+
logger: api.logger,
|
|
85
|
+
notifier,
|
|
86
|
+
acpClient,
|
|
87
|
+
pollIntervalMs: config.watcherPollIntervalMs,
|
|
88
|
+
});
|
|
89
|
+
service = new ClawSpecService({
|
|
90
|
+
api,
|
|
91
|
+
config: api.config,
|
|
92
|
+
logger: api.logger,
|
|
93
|
+
stateStore,
|
|
94
|
+
memoryStore,
|
|
95
|
+
openSpec,
|
|
96
|
+
archiveDirName: config.archiveDirName,
|
|
97
|
+
allowedChannels: config.allowedChannels,
|
|
98
|
+
defaultWorkspace: config.defaultWorkspace,
|
|
99
|
+
defaultWorkerAgentId: config.workerAgentId,
|
|
100
|
+
workspaceStore,
|
|
101
|
+
watcherManager,
|
|
102
|
+
});
|
|
103
|
+
await watcherManager.start();
|
|
104
|
+
},
|
|
105
|
+
async stop() {
|
|
106
|
+
await watcherManager?.stop();
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
api.registerCli(
|
|
111
|
+
({ program, logger }) => {
|
|
112
|
+
program
|
|
113
|
+
.command("clawspec-projects")
|
|
114
|
+
.description("List saved ClawSpec workspaces")
|
|
115
|
+
.action(async () => {
|
|
116
|
+
await initStores();
|
|
117
|
+
const entries = await workspaceStore.list();
|
|
118
|
+
if (entries.length === 0) {
|
|
119
|
+
logger.info("No remembered ClawSpec workspaces.");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
logger.info(entry.path);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
{ commands: ["clawspec-projects"] },
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
api.registerCommand({
|
|
131
|
+
name: "clawspec",
|
|
132
|
+
description: "Manage a ClawSpec project workflow",
|
|
133
|
+
acceptsArgs: true,
|
|
134
|
+
requireAuth: true,
|
|
135
|
+
handler: async (ctx) => {
|
|
136
|
+
await initStores();
|
|
137
|
+
if (!service) {
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
text: "ClawSpec is still bootstrapping dependencies. Try again in a moment.",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return service.handleProjectCommand(ctx);
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
api.on("message_received", async (event, ctx) => {
|
|
148
|
+
await stateStore.initialize();
|
|
149
|
+
if (!service) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
await service.recordPlanningMessageFromContext({
|
|
153
|
+
channel: ctx.channel ?? ctx.messageProvider ?? ctx.channelId,
|
|
154
|
+
channelId: ctx.channelId,
|
|
155
|
+
accountId: ctx.accountId,
|
|
156
|
+
conversationId: ctx.conversationId,
|
|
157
|
+
sessionKey: (ctx as { sessionKey?: string }).sessionKey,
|
|
158
|
+
}, event.content);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
api.on("before_prompt_build", async (event, ctx) => {
|
|
162
|
+
await stateStore.initialize();
|
|
163
|
+
if (!service) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
return service.handleBeforePromptBuild(event, ctx);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
api.on("agent_end", async (event, ctx) => {
|
|
170
|
+
await stateStore.initialize();
|
|
171
|
+
if (!service) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
await service.handleAgentEnd(event, ctx);
|
|
175
|
+
});
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export default plugin;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { ensureDir, pathExists, readJsonFile, writeJsonFile } from "../utils/fs.ts";
|
|
3
|
+
import type { ProjectMemoryFile, RememberedProject } from "../types.ts";
|
|
4
|
+
|
|
5
|
+
export class DuplicateRememberedProjectError extends Error {
|
|
6
|
+
readonly existing: RememberedProject;
|
|
7
|
+
|
|
8
|
+
constructor(existing: RememberedProject) {
|
|
9
|
+
super(`Remembered project "${existing.name}" already exists.`);
|
|
10
|
+
this.existing = existing;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class RememberedProjectNotFoundError extends Error {
|
|
15
|
+
constructor(name: string) {
|
|
16
|
+
super(`Remembered project "${name}" was not found.`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class RememberedProjectPathInvalidError extends Error {
|
|
21
|
+
readonly entry: RememberedProject;
|
|
22
|
+
|
|
23
|
+
constructor(entry: RememberedProject) {
|
|
24
|
+
super(`Remembered project "${entry.name}" points to an invalid path: ${entry.repoPath}`);
|
|
25
|
+
this.entry = entry;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class ProjectMemoryStore {
|
|
30
|
+
readonly filePath: string;
|
|
31
|
+
private initPromise: Promise<void> | undefined;
|
|
32
|
+
|
|
33
|
+
constructor(filePath: string) {
|
|
34
|
+
this.filePath = filePath;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async initialize(): Promise<void> {
|
|
38
|
+
this.initPromise ??= this.doInitialize();
|
|
39
|
+
return this.initPromise;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private async doInitialize(): Promise<void> {
|
|
43
|
+
await ensureDir(path.dirname(this.filePath));
|
|
44
|
+
if (!(await pathExists(this.filePath))) {
|
|
45
|
+
await this.writeRegistry({ version: 1, projects: [] });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async list(): Promise<RememberedProject[]> {
|
|
50
|
+
const registry = await this.readRegistry();
|
|
51
|
+
return [...registry.projects].sort((left, right) => left.name.localeCompare(right.name));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async get(name: string): Promise<RememberedProject | null> {
|
|
55
|
+
const normalizedName = normalizeName(name);
|
|
56
|
+
const registry = await this.readRegistry();
|
|
57
|
+
return registry.projects.find((entry) => entry.normalizedName === normalizedName) ?? null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async remember(
|
|
61
|
+
name: string,
|
|
62
|
+
repoPath: string,
|
|
63
|
+
options?: { overwrite?: boolean },
|
|
64
|
+
): Promise<{ entry: RememberedProject; created: boolean; overwritten: boolean }> {
|
|
65
|
+
const overwrite = options?.overwrite === true;
|
|
66
|
+
const normalizedName = normalizeName(name);
|
|
67
|
+
const registry = await this.readRegistry();
|
|
68
|
+
const now = new Date().toISOString();
|
|
69
|
+
const existing = registry.projects.find((entry) => entry.normalizedName === normalizedName);
|
|
70
|
+
if (existing && !overwrite) {
|
|
71
|
+
throw new DuplicateRememberedProjectError(existing);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const entry: RememberedProject = {
|
|
75
|
+
name: name.trim(),
|
|
76
|
+
normalizedName,
|
|
77
|
+
repoPath: path.resolve(repoPath),
|
|
78
|
+
createdAt: existing?.createdAt ?? now,
|
|
79
|
+
updatedAt: now,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
registry.projects = registry.projects.filter((candidate) => candidate.normalizedName !== normalizedName);
|
|
83
|
+
registry.projects.push(entry);
|
|
84
|
+
await this.writeRegistry(registry);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
entry,
|
|
88
|
+
created: !existing,
|
|
89
|
+
overwritten: Boolean(existing),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async resolveForUse(name: string): Promise<RememberedProject> {
|
|
94
|
+
const entry = await this.get(name);
|
|
95
|
+
if (!entry) {
|
|
96
|
+
throw new RememberedProjectNotFoundError(name);
|
|
97
|
+
}
|
|
98
|
+
if (!(await pathExists(entry.repoPath))) {
|
|
99
|
+
throw new RememberedProjectPathInvalidError(entry);
|
|
100
|
+
}
|
|
101
|
+
return entry;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private async readRegistry(): Promise<ProjectMemoryFile> {
|
|
105
|
+
return readJsonFile<ProjectMemoryFile>(this.filePath, {
|
|
106
|
+
version: 1,
|
|
107
|
+
projects: [],
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private async writeRegistry(value: ProjectMemoryFile): Promise<void> {
|
|
112
|
+
await writeJsonFile(this.filePath, value);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeName(name: string): string {
|
|
117
|
+
return name.trim().toLowerCase();
|
|
118
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { stripAnsi } from "../utils/fs.ts";
|
|
3
|
+
import { prependPathEntries } from "../utils/env-path.ts";
|
|
4
|
+
import type {
|
|
5
|
+
OpenSpecApplyInstructionsResponse,
|
|
6
|
+
OpenSpecCommandResult,
|
|
7
|
+
OpenSpecInstructionsResponse,
|
|
8
|
+
OpenSpecStatusResponse,
|
|
9
|
+
OpenSpecValidationResponse,
|
|
10
|
+
} from "../types.ts";
|
|
11
|
+
|
|
12
|
+
type OpenSpecClientOptions = {
|
|
13
|
+
timeoutMs: number;
|
|
14
|
+
command?: string;
|
|
15
|
+
extraPathEntries?: string[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export class OpenSpecCommandError extends Error {
|
|
19
|
+
readonly result: OpenSpecCommandResult<unknown>;
|
|
20
|
+
|
|
21
|
+
constructor(message: string, result: OpenSpecCommandResult<unknown>) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.result = result;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class OpenSpecClient {
|
|
28
|
+
readonly timeoutMs: number;
|
|
29
|
+
readonly command: string;
|
|
30
|
+
readonly extraPathEntries: string[];
|
|
31
|
+
|
|
32
|
+
constructor(options: OpenSpecClientOptions) {
|
|
33
|
+
this.timeoutMs = options.timeoutMs;
|
|
34
|
+
this.command = options.command ?? "openspec";
|
|
35
|
+
this.extraPathEntries = options.extraPathEntries ?? [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async init(repoPath: string): Promise<OpenSpecCommandResult> {
|
|
39
|
+
return this.runText(["init", "--tools", "none", "."], repoPath);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async newChange(
|
|
43
|
+
repoPath: string,
|
|
44
|
+
changeName: string,
|
|
45
|
+
description?: string,
|
|
46
|
+
): Promise<OpenSpecCommandResult> {
|
|
47
|
+
const args = ["new", "change", changeName];
|
|
48
|
+
if (description && description.trim().length > 0) {
|
|
49
|
+
args.push("--description", description);
|
|
50
|
+
}
|
|
51
|
+
return this.runText(args, repoPath);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async status(repoPath: string, changeName: string): Promise<OpenSpecCommandResult<OpenSpecStatusResponse>> {
|
|
55
|
+
return this.runJson<OpenSpecStatusResponse>(
|
|
56
|
+
["status", "--change", changeName, "--json"],
|
|
57
|
+
repoPath,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async instructionsArtifact(
|
|
62
|
+
repoPath: string,
|
|
63
|
+
artifactId: string,
|
|
64
|
+
changeName: string,
|
|
65
|
+
): Promise<OpenSpecCommandResult<OpenSpecInstructionsResponse>> {
|
|
66
|
+
return this.runJson<OpenSpecInstructionsResponse>(
|
|
67
|
+
["instructions", artifactId, "--change", changeName, "--json"],
|
|
68
|
+
repoPath,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async instructionsApply(
|
|
73
|
+
repoPath: string,
|
|
74
|
+
changeName: string,
|
|
75
|
+
): Promise<OpenSpecCommandResult<OpenSpecApplyInstructionsResponse>> {
|
|
76
|
+
return this.runJson<OpenSpecApplyInstructionsResponse>(
|
|
77
|
+
["instructions", "apply", "--change", changeName, "--json"],
|
|
78
|
+
repoPath,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async validate(repoPath: string, changeName: string): Promise<OpenSpecCommandResult<OpenSpecValidationResponse>> {
|
|
83
|
+
return this.runJson<OpenSpecValidationResponse>(
|
|
84
|
+
["validate", changeName, "--type", "change", "--json", "--no-interactive"],
|
|
85
|
+
repoPath,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async archive(repoPath: string, changeName: string): Promise<OpenSpecCommandResult> {
|
|
90
|
+
return this.runText(["archive", changeName, "-y"], repoPath);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private async runText(args: string[], cwd: string): Promise<OpenSpecCommandResult> {
|
|
94
|
+
return this.execute(args, cwd);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private async runJson<T>(args: string[], cwd: string): Promise<OpenSpecCommandResult<T>> {
|
|
98
|
+
return this.execute<T>(args, cwd);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private execute<T>(args: string[], cwd: string): Promise<OpenSpecCommandResult<T>> {
|
|
102
|
+
const commandLabel = buildShellCommand(this.command, args);
|
|
103
|
+
const startedAt = Date.now();
|
|
104
|
+
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
const child = spawn(commandLabel, {
|
|
107
|
+
cwd,
|
|
108
|
+
shell: true,
|
|
109
|
+
windowsHide: true,
|
|
110
|
+
env: prependPathEntries({
|
|
111
|
+
...process.env,
|
|
112
|
+
FORCE_COLOR: "0",
|
|
113
|
+
NO_COLOR: "1",
|
|
114
|
+
CI: "1",
|
|
115
|
+
}, this.extraPathEntries),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
let stdout = "";
|
|
119
|
+
let stderr = "";
|
|
120
|
+
let completed = false;
|
|
121
|
+
|
|
122
|
+
const timer = setTimeout(() => {
|
|
123
|
+
child.kill();
|
|
124
|
+
}, this.timeoutMs);
|
|
125
|
+
|
|
126
|
+
child.stdout.setEncoding("utf8");
|
|
127
|
+
child.stderr.setEncoding("utf8");
|
|
128
|
+
child.stdout.on("data", (chunk) => {
|
|
129
|
+
stdout += chunk;
|
|
130
|
+
});
|
|
131
|
+
child.stderr.on("data", (chunk) => {
|
|
132
|
+
stderr += chunk;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
child.on("error", (error) => {
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
completed = true;
|
|
138
|
+
reject(
|
|
139
|
+
new OpenSpecCommandError(error.message, {
|
|
140
|
+
command: commandLabel,
|
|
141
|
+
cwd,
|
|
142
|
+
stdout,
|
|
143
|
+
stderr,
|
|
144
|
+
durationMs: Date.now() - startedAt,
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
child.on("close", (exitCode) => {
|
|
150
|
+
if (completed) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
const result: OpenSpecCommandResult<T> = {
|
|
155
|
+
command: commandLabel,
|
|
156
|
+
cwd,
|
|
157
|
+
stdout: stripAnsi(stdout),
|
|
158
|
+
stderr: stripAnsi(stderr),
|
|
159
|
+
durationMs: Date.now() - startedAt,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
if (exitCode !== 0) {
|
|
163
|
+
reject(new OpenSpecCommandError(`OpenSpec command failed: ${commandLabel}`, result));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (args.includes("--json")) {
|
|
168
|
+
try {
|
|
169
|
+
result.parsed = extractJsonFromMixedOutput<T>(`${stdout}\n${stderr}`);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
reject(
|
|
172
|
+
new OpenSpecCommandError(
|
|
173
|
+
error instanceof Error ? error.message : "Failed to parse OpenSpec JSON output.",
|
|
174
|
+
result,
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
resolve(result);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function buildShellCommand(command: string, args: string[]): string {
|
|
188
|
+
return [command, ...args].map((arg) => quoteShellArg(arg)).join(" ");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function quoteShellArg(arg: string): string {
|
|
192
|
+
if (process.platform === "win32") {
|
|
193
|
+
return quoteWindowsShellArg(arg);
|
|
194
|
+
}
|
|
195
|
+
return quotePosixShellArg(arg);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function quoteWindowsShellArg(arg: string): string {
|
|
199
|
+
if (arg.length === 0) {
|
|
200
|
+
return "\"\"";
|
|
201
|
+
}
|
|
202
|
+
const escaped = arg
|
|
203
|
+
.replace(/"/g, "\"\"")
|
|
204
|
+
.replace(/%/g, "%%");
|
|
205
|
+
if (!/[\s"&|<>^()!]/.test(arg)) {
|
|
206
|
+
return escaped;
|
|
207
|
+
}
|
|
208
|
+
return `"${escaped}"`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function quotePosixShellArg(arg: string): string {
|
|
212
|
+
if (arg.length === 0) {
|
|
213
|
+
return "''";
|
|
214
|
+
}
|
|
215
|
+
if (/^[A-Za-z0-9_./:=+-]+$/.test(arg)) {
|
|
216
|
+
return arg;
|
|
217
|
+
}
|
|
218
|
+
return `'${arg.replace(/'/g, `'\\''`)}'`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function extractJsonFromMixedOutput<T>(text: string): T {
|
|
222
|
+
const cleaned = stripAnsi(text);
|
|
223
|
+
const firstBraceIndex = cleaned.search(/[\[{]/);
|
|
224
|
+
if (firstBraceIndex === -1) {
|
|
225
|
+
throw new Error("No JSON object or array found in OpenSpec output.");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const jsonSlice = findBalancedJson(cleaned.slice(firstBraceIndex));
|
|
229
|
+
return JSON.parse(jsonSlice) as T;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function findBalancedJson(text: string): string {
|
|
233
|
+
let depth = 0;
|
|
234
|
+
let inString = false;
|
|
235
|
+
let escaped = false;
|
|
236
|
+
let started = false;
|
|
237
|
+
|
|
238
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
239
|
+
const char = text[index];
|
|
240
|
+
|
|
241
|
+
if (!started) {
|
|
242
|
+
if (char === "{" || char === "[") {
|
|
243
|
+
started = true;
|
|
244
|
+
depth = 1;
|
|
245
|
+
}
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (inString) {
|
|
250
|
+
if (escaped) {
|
|
251
|
+
escaped = false;
|
|
252
|
+
} else if (char === "\\") {
|
|
253
|
+
escaped = true;
|
|
254
|
+
} else if (char === "\"") {
|
|
255
|
+
inString = false;
|
|
256
|
+
}
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (char === "\"") {
|
|
261
|
+
inString = true;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (char === "{" || char === "[") {
|
|
266
|
+
depth += 1;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (char === "}" || char === "]") {
|
|
271
|
+
depth -= 1;
|
|
272
|
+
if (depth === 0) {
|
|
273
|
+
return text.slice(0, index + 1);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
throw new Error("OpenSpec output contained incomplete JSON.");
|
|
279
|
+
}
|