@wkronmiller/lisa 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +407 -0
  3. package/bin/lisa-runtime.js +8797 -0
  4. package/bin/lisa.js +21 -0
  5. package/completion.ts +58 -0
  6. package/install.ps1 +51 -0
  7. package/install.sh +93 -0
  8. package/lisa.ts +6 -0
  9. package/package.json +66 -0
  10. package/skills/README.md +28 -0
  11. package/skills/claude-code/CLAUDE.md +151 -0
  12. package/skills/codex/AGENTS.md +151 -0
  13. package/skills/gemini/GEMINI.md +151 -0
  14. package/skills/opencode/AGENTS.md +152 -0
  15. package/src/cli.ts +85 -0
  16. package/src/harness/base-adapter.ts +47 -0
  17. package/src/harness/claude-code.ts +106 -0
  18. package/src/harness/codex.ts +80 -0
  19. package/src/harness/command.ts +173 -0
  20. package/src/harness/gemini.ts +74 -0
  21. package/src/harness/opencode.ts +84 -0
  22. package/src/harness/registry.ts +29 -0
  23. package/src/harness/runner.ts +19 -0
  24. package/src/harness/types.ts +73 -0
  25. package/src/output-mode.ts +32 -0
  26. package/src/skill/artifacts.ts +174 -0
  27. package/src/skill/cli.ts +29 -0
  28. package/src/skill/install.ts +317 -0
  29. package/src/spec/agent-guidance.ts +466 -0
  30. package/src/spec/cli.ts +151 -0
  31. package/src/spec/commands/check.ts +1 -0
  32. package/src/spec/commands/config.ts +146 -0
  33. package/src/spec/commands/diff.ts +1 -0
  34. package/src/spec/commands/generate.ts +1 -0
  35. package/src/spec/commands/guide.ts +1 -0
  36. package/src/spec/commands/harness-list.ts +36 -0
  37. package/src/spec/commands/implement.ts +1 -0
  38. package/src/spec/commands/import.ts +1 -0
  39. package/src/spec/commands/init.ts +1 -0
  40. package/src/spec/commands/status.ts +87 -0
  41. package/src/spec/config.ts +63 -0
  42. package/src/spec/diff.ts +791 -0
  43. package/src/spec/extensions/benchmark.ts +347 -0
  44. package/src/spec/extensions/registry.ts +59 -0
  45. package/src/spec/extensions/types.ts +56 -0
  46. package/src/spec/grammar/index.ts +14 -0
  47. package/src/spec/grammar/parser.ts +443 -0
  48. package/src/spec/grammar/types.ts +70 -0
  49. package/src/spec/grammar/validator.ts +104 -0
  50. package/src/spec/loader.ts +174 -0
  51. package/src/spec/local-config.ts +59 -0
  52. package/src/spec/parser.ts +226 -0
  53. package/src/spec/path-utils.ts +73 -0
  54. package/src/spec/planner.ts +299 -0
  55. package/src/spec/prompt-renderer.ts +318 -0
  56. package/src/spec/skill-content.ts +119 -0
  57. package/src/spec/types.ts +239 -0
  58. package/src/spec/validator.ts +443 -0
  59. package/src/spec/workflows/check.ts +1534 -0
  60. package/src/spec/workflows/diff.ts +209 -0
  61. package/src/spec/workflows/generate.ts +1270 -0
  62. package/src/spec/workflows/guide.ts +190 -0
  63. package/src/spec/workflows/implement.ts +797 -0
  64. package/src/spec/workflows/import.ts +986 -0
  65. package/src/spec/workflows/init.ts +548 -0
  66. package/src/spec/workflows/status.ts +22 -0
  67. package/src/spec/workspace.ts +541 -0
  68. package/uninstall.ps1 +21 -0
  69. package/uninstall.sh +22 -0
@@ -0,0 +1,173 @@
1
+ import { existsSync, statSync } from "fs";
2
+ import { isAbsolute, resolve } from "path";
3
+
4
+ import type { HarnessAvailability, HarnessRequest, HarnessResult } from "./types";
5
+
6
+ const IS_WINDOWS = process.platform === "win32";
7
+ const LISA_ABORT_PREFIX = "LISA_ABORT:";
8
+ const LISA_ABORT_START = "LISA_ABORT_START";
9
+ const LISA_ABORT_END = "LISA_ABORT_END";
10
+
11
+ function looksLikePathCommand(command: string): boolean {
12
+ return command.includes("/") || command.includes("\\");
13
+ }
14
+
15
+ export function resolveHarnessCommandOverride(command: string, workspaceRoot: string): string {
16
+ return !isAbsolute(command) && looksLikePathCommand(command) ? resolve(workspaceRoot, command) : command;
17
+ }
18
+
19
+ function resolveConfiguredCommand(envVarNames: string[]): string | undefined {
20
+ for (const envVar of envVarNames) {
21
+ const value = process.env[envVar]?.trim();
22
+ if (value) {
23
+ return value;
24
+ }
25
+ }
26
+
27
+ return undefined;
28
+ }
29
+
30
+ export function resolveConfiguredCommandAvailability(command: string, cwd = process.cwd()): HarnessAvailability {
31
+ const pathCandidate = !isAbsolute(command) && looksLikePathCommand(command) ? resolve(cwd, command) : command;
32
+ if (existsSync(pathCandidate)) {
33
+ const stats = statSync(pathCandidate);
34
+ const isExecutableFile = stats.isFile() && (IS_WINDOWS || (stats.mode & 0o111) !== 0);
35
+ if (!isExecutableFile) {
36
+ return {
37
+ available: false,
38
+ reason: `Configured harness command \`${command}\` is not an executable file.`,
39
+ };
40
+ }
41
+
42
+ return {
43
+ available: true,
44
+ command: pathCandidate,
45
+ reason: "Configured harness command found via environment override.",
46
+ };
47
+ }
48
+
49
+ const resolved = IS_WINDOWS ? resolveWindowsCommand(command) : Bun.which(command);
50
+ if (resolved) {
51
+ return {
52
+ available: true,
53
+ command: resolved,
54
+ reason: "Configured harness command found via environment override.",
55
+ };
56
+ }
57
+
58
+ return {
59
+ available: false,
60
+ reason: `Configured harness command \`${command}\` was not found.`,
61
+ };
62
+ }
63
+
64
+ export function detectExplicitHarnessCommand(command: string, cwd = process.cwd()): HarnessAvailability {
65
+ return resolveConfiguredCommandAvailability(command, cwd);
66
+ }
67
+
68
+ function resolveWindowsCommand(command: string): string | undefined {
69
+ const direct = Bun.which(command);
70
+ if (direct) {
71
+ return direct;
72
+ }
73
+
74
+ if (!command.toLowerCase().endsWith(".cmd")) {
75
+ return Bun.which(`${command}.cmd`) ?? undefined;
76
+ }
77
+
78
+ return undefined;
79
+ }
80
+
81
+ export function resolveHarnessCommand(command: string, envVarNames: string[] = []): string | undefined {
82
+ const configured = resolveConfiguredCommand(envVarNames);
83
+ if (configured) {
84
+ return configured;
85
+ }
86
+
87
+ if (IS_WINDOWS) {
88
+ return resolveWindowsCommand(command) ?? command;
89
+ }
90
+
91
+ return Bun.which(command) ?? command;
92
+ }
93
+
94
+ export function detectHarnessCommand(
95
+ command: string,
96
+ envVarNames: string[] = [],
97
+ ): HarnessAvailability {
98
+ const configured = resolveConfiguredCommand(envVarNames);
99
+ if (configured) {
100
+ return resolveConfiguredCommandAvailability(configured);
101
+ }
102
+
103
+ const resolved = IS_WINDOWS ? resolveWindowsCommand(command) : Bun.which(command);
104
+ if (resolved) {
105
+ return {
106
+ available: true,
107
+ command: resolved,
108
+ reason: "Harness CLI detected in PATH.",
109
+ };
110
+ }
111
+
112
+ return {
113
+ available: false,
114
+ reason: `Command \`${command}\` was not found in PATH.`,
115
+ };
116
+ }
117
+
118
+ function decodeOutput(output: Uint8Array | null | undefined): string {
119
+ if (!output) {
120
+ return "";
121
+ }
122
+
123
+ return new TextDecoder().decode(output);
124
+ }
125
+
126
+ function extractAbortReason(output: string): string | undefined {
127
+ const trimmed = output.trim();
128
+ const blockMatch = trimmed.match(/(?:^|\n)LISA_ABORT_START\s*([\s\S]*?)\s*LISA_ABORT_END(?:\n|$)/);
129
+ if (blockMatch?.[1]) {
130
+ return blockMatch[1].trim() || undefined;
131
+ }
132
+
133
+ const nonEmptyLines = trimmed.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
134
+ const lastLine = nonEmptyLines[nonEmptyLines.length - 1];
135
+ if (lastLine?.startsWith(LISA_ABORT_PREFIX)) {
136
+ return lastLine.slice(LISA_ABORT_PREFIX.length).trim() || "Harness blocked without a reason.";
137
+ }
138
+
139
+ return undefined;
140
+ }
141
+
142
+ export function runHarnessCommand(command: string, args: string[], request: HarnessRequest): HarnessResult {
143
+ const proc = Bun.spawnSync({
144
+ cmd: [command, ...args],
145
+ cwd: request.cwd,
146
+ stdout: "pipe",
147
+ stderr: "pipe",
148
+ env: {
149
+ ...process.env,
150
+ ...request.env,
151
+ },
152
+ timeout: request.limits?.timeoutSeconds ? request.limits.timeoutSeconds * 1000 : undefined,
153
+ });
154
+
155
+ const stdout = decodeOutput(proc.stdout);
156
+ const stderr = decodeOutput(proc.stderr);
157
+ const combined = [stdout.trim(), stderr.trim()].filter((entry) => entry.length > 0).join("\n\n");
158
+ const abortReason = extractAbortReason(combined);
159
+
160
+ return {
161
+ status: abortReason ? "blocked" : proc.exitCode === 0 ? "success" : "failed",
162
+ finalText: abortReason ?? combined,
163
+ abortReason,
164
+ rawEvents: [
165
+ {
166
+ command: [command, ...args],
167
+ exitCode: proc.exitCode,
168
+ stdout,
169
+ stderr,
170
+ },
171
+ ],
172
+ };
173
+ }
@@ -0,0 +1,74 @@
1
+ import { resolveGeminiHome } from "../skill/artifacts";
2
+ import { createBaseAdapterHelpers } from "./base-adapter";
3
+ import { resolveHarnessCommand, runHarnessCommand } from "./command";
4
+ import type {
5
+ HarnessAdapter,
6
+ HarnessCapabilities,
7
+ HarnessRequest,
8
+ HarnessResult,
9
+ Stage,
10
+ } from "./types";
11
+
12
+ const SUPPORTED_STAGES: Stage[] = ["generate", "implement", "check", "import"];
13
+ const GEMINI_ENV_VARS = ["LISA_GEMINI_BINARY", "RALPH_GEMINI_BINARY"];
14
+
15
+ function getGeminiCapabilities(): HarnessCapabilities {
16
+ return {
17
+ canEditFiles: true,
18
+ canResumeSessions: false,
19
+ supportsStructuredOutput: false,
20
+ supportsStreaming: false,
21
+ supportsModelSelection: true,
22
+ supportsReadOnlyMode: true,
23
+ };
24
+ }
25
+
26
+ function buildGeminiArgs(request: HarnessRequest): string[] {
27
+ const args: string[] = ["--approval-mode", request.allowEdits ? "yolo" : "plan"];
28
+
29
+ if (request.model) {
30
+ args.push("-m", request.model);
31
+ }
32
+
33
+ if (request.extraArgs?.length) {
34
+ args.push(...request.extraArgs);
35
+ }
36
+
37
+ args.push("-p", request.prompt);
38
+ return args;
39
+ }
40
+
41
+ export function createGeminiHarnessAdapter(cwd = process.cwd()): HarnessAdapter {
42
+ const baseHelpers = createBaseAdapterHelpers(
43
+ {
44
+ harnessId: "gemini",
45
+ commandName: "gemini",
46
+ envVars: GEMINI_ENV_VARS,
47
+ artifactName: "GEMINI.md",
48
+ resolveGlobalRoot: () => resolveGeminiHome(),
49
+ },
50
+ cwd
51
+ );
52
+
53
+ return {
54
+ id: "gemini",
55
+ displayName: "Gemini CLI",
56
+ description: "Direct adapter for the Google Gemini CLI.",
57
+ supportedStages: SUPPORTED_STAGES,
58
+ ...baseHelpers,
59
+ async capabilities(): Promise<HarnessCapabilities> {
60
+ return getGeminiCapabilities();
61
+ },
62
+ async run(request: HarnessRequest): Promise<HarnessResult> {
63
+ const command = request.env?.LISA_HARNESS_COMMAND ?? resolveHarnessCommand("gemini", GEMINI_ENV_VARS);
64
+ if (!command) {
65
+ return {
66
+ status: "failed",
67
+ finalText: "Gemini CLI not found.",
68
+ };
69
+ }
70
+
71
+ return runHarnessCommand(command, buildGeminiArgs(request), request);
72
+ },
73
+ };
74
+ }
@@ -0,0 +1,84 @@
1
+ import { join } from "path";
2
+
3
+ import { renderOpenCodeSkill, resolveLocalSkillWorkspace, resolveXdgConfigHome } from "../skill/artifacts";
4
+ import { createBaseAdapterHelpers } from "./base-adapter";
5
+ import { resolveHarnessCommand, runHarnessCommand } from "./command";
6
+ import type {
7
+ HarnessAdapter,
8
+ HarnessCapabilities,
9
+ HarnessRequest,
10
+ HarnessResult,
11
+ Stage,
12
+ } from "./types";
13
+
14
+ const SUPPORTED_STAGES: Stage[] = ["generate", "implement", "check", "import"];
15
+ const OPENCODE_ENV_VARS = ["LISA_OPENCODE_BINARY", "RALPH_OPENCODE_BINARY"];
16
+
17
+ function getOpenCodeCapabilities(): HarnessCapabilities {
18
+ return {
19
+ canEditFiles: true,
20
+ canResumeSessions: false,
21
+ supportsStructuredOutput: false,
22
+ supportsStreaming: false,
23
+ supportsModelSelection: true,
24
+ supportsReadOnlyMode: false,
25
+ };
26
+ }
27
+
28
+ function buildOpenCodeArgs(request: HarnessRequest): string[] {
29
+ const args = ["run"];
30
+
31
+ if (request.model) {
32
+ args.push("-m", request.model);
33
+ }
34
+
35
+ for (const contextFile of request.contextFiles ?? []) {
36
+ args.push("--file", contextFile);
37
+ }
38
+
39
+ if (request.extraArgs?.length) {
40
+ args.push(...request.extraArgs);
41
+ }
42
+
43
+ args.push("--");
44
+ args.push(request.prompt);
45
+ return args;
46
+ }
47
+
48
+ export function createOpenCodeHarnessAdapter(cwd = process.cwd()): HarnessAdapter {
49
+ const baseHelpers = createBaseAdapterHelpers(
50
+ {
51
+ harnessId: "opencode",
52
+ commandName: "opencode",
53
+ envVars: OPENCODE_ENV_VARS,
54
+ artifactName: "AGENTS.md",
55
+ resolveGlobalRoot: () => join(resolveXdgConfigHome(), "opencode", "skills", "lisa"),
56
+ resolveGlobalDestination: () => join(resolveXdgConfigHome(), "opencode", "skills", "lisa", "SKILL.md"),
57
+ resolveLocalDestination: (workspaceDir) => join(resolveLocalSkillWorkspace(workspaceDir), ".opencode", "skills", "lisa", "SKILL.md"),
58
+ renderContent: renderOpenCodeSkill,
59
+ },
60
+ cwd
61
+ );
62
+
63
+ return {
64
+ id: "opencode",
65
+ displayName: "OpenCode",
66
+ description: "Direct adapter for the OpenCode CLI.",
67
+ supportedStages: SUPPORTED_STAGES,
68
+ ...baseHelpers,
69
+ async capabilities(): Promise<HarnessCapabilities> {
70
+ return getOpenCodeCapabilities();
71
+ },
72
+ async run(request: HarnessRequest): Promise<HarnessResult> {
73
+ const command = request.env?.LISA_HARNESS_COMMAND ?? resolveHarnessCommand("opencode", OPENCODE_ENV_VARS);
74
+ if (!command) {
75
+ return {
76
+ status: "failed",
77
+ finalText: "OpenCode CLI not found.",
78
+ };
79
+ }
80
+
81
+ return runHarnessCommand(command, buildOpenCodeArgs(request), request);
82
+ },
83
+ };
84
+ }
@@ -0,0 +1,29 @@
1
+ import { createClaudeCodeHarnessAdapter } from "./claude-code";
2
+ import { createCodexHarnessAdapter } from "./codex";
3
+ import { createGeminiHarnessAdapter } from "./gemini";
4
+ import { createOpenCodeHarnessAdapter } from "./opencode";
5
+ import type { HarnessAdapter, HarnessInspection } from "./types";
6
+
7
+ export function getHarnessAdapters(cwd = process.cwd()): HarnessAdapter[] {
8
+ return [
9
+ createCodexHarnessAdapter(cwd),
10
+ createOpenCodeHarnessAdapter(cwd),
11
+ createClaudeCodeHarnessAdapter(cwd),
12
+ createGeminiHarnessAdapter(cwd),
13
+ ];
14
+ }
15
+
16
+ export function getHarnessAdapterById(id: string, cwd = process.cwd()): HarnessAdapter | undefined {
17
+ return getHarnessAdapters(cwd).find((adapter) => adapter.id === id);
18
+ }
19
+
20
+ export async function inspectHarnesses(cwd = process.cwd()): Promise<HarnessInspection[]> {
21
+ const adapters = getHarnessAdapters(cwd);
22
+ return Promise.all(
23
+ adapters.map(async (adapter) => ({
24
+ adapter,
25
+ availability: await adapter.detect(),
26
+ capabilities: await adapter.capabilities(),
27
+ })),
28
+ );
29
+ }
@@ -0,0 +1,19 @@
1
+ import { getHarnessAdapterById } from "./registry";
2
+ import { detectExplicitHarnessCommand } from "./command";
3
+ import type { HarnessRequest, HarnessResult } from "./types";
4
+
5
+ export async function runHarnessStage(harnessId: string, request: HarnessRequest, cwd = process.cwd()): Promise<HarnessResult> {
6
+ const adapter = getHarnessAdapterById(harnessId, cwd);
7
+ if (!adapter) {
8
+ throw new Error(`No harness adapter is registered for \`${harnessId}\`.`);
9
+ }
10
+
11
+ const availability = request.env?.LISA_HARNESS_COMMAND
12
+ ? detectExplicitHarnessCommand(request.env.LISA_HARNESS_COMMAND, cwd)
13
+ : await adapter.detect();
14
+ if (!availability.available) {
15
+ throw new Error(`Harness \`${harnessId}\` is unavailable: ${availability.reason}`);
16
+ }
17
+
18
+ return adapter.run(request);
19
+ }
@@ -0,0 +1,73 @@
1
+ export type Stage = "generate" | "implement" | "check" | "import";
2
+ export type SkillInstallScope = "global" | "local";
3
+
4
+ export interface HarnessRequest {
5
+ stage: Stage;
6
+ prompt: string;
7
+ cwd: string;
8
+ allowEdits: boolean;
9
+ contextFiles?: string[];
10
+ model?: string;
11
+ profile?: string;
12
+ outputSchema?: unknown;
13
+ extraArgs?: string[];
14
+ env?: Record<string, string>;
15
+ limits?: {
16
+ maxTurns?: number;
17
+ timeoutSeconds?: number;
18
+ };
19
+ }
20
+
21
+ export interface HarnessResult {
22
+ status: "success" | "failed" | "needs_input" | "blocked";
23
+ finalText?: string;
24
+ structuredOutput?: unknown;
25
+ sessionId?: string;
26
+ changedFiles?: string[];
27
+ abortReason?: string;
28
+ rawEvents?: unknown[];
29
+ }
30
+
31
+ export interface HarnessCapabilities {
32
+ canEditFiles: boolean;
33
+ canResumeSessions: boolean;
34
+ supportsStructuredOutput: boolean;
35
+ supportsStreaming: boolean;
36
+ supportsModelSelection: boolean;
37
+ supportsReadOnlyMode: boolean;
38
+ }
39
+
40
+ export interface HarnessAvailability {
41
+ available: boolean;
42
+ command?: string;
43
+ reason: string;
44
+ }
45
+
46
+ export interface HarnessSkillInstallDefinition {
47
+ scope: SkillInstallScope;
48
+ sourcePath: string;
49
+ destinationPath: string;
50
+ }
51
+
52
+ export interface HarnessSkillInstallResult extends HarnessSkillInstallDefinition {
53
+ status: "created" | "updated" | "unchanged";
54
+ }
55
+
56
+ export interface HarnessAdapter {
57
+ id: string;
58
+ displayName: string;
59
+ description: string;
60
+ supportedStages: Stage[];
61
+ detect(): Promise<HarnessAvailability>;
62
+ capabilities(): Promise<HarnessCapabilities>;
63
+ run(request: HarnessRequest): Promise<HarnessResult>;
64
+ getSkillInstallDefinition?(scope: SkillInstallScope): Promise<HarnessSkillInstallDefinition>;
65
+ installSkill?(scope: SkillInstallScope): Promise<HarnessSkillInstallResult>;
66
+ resume?(sessionId: string, request: HarnessRequest): Promise<HarnessResult>;
67
+ }
68
+
69
+ export interface HarnessInspection {
70
+ adapter: HarnessAdapter;
71
+ availability: HarnessAvailability;
72
+ capabilities: HarnessCapabilities;
73
+ }
@@ -0,0 +1,32 @@
1
+ export type LisaOutputMode = "interactive" | "concise";
2
+
3
+ type OutputModeOptions = {
4
+ env?: Record<string, string | undefined>;
5
+ isStdoutTTY?: boolean;
6
+ };
7
+
8
+ function resolveOverride(env: Record<string, string | undefined>): LisaOutputMode | undefined {
9
+ const override = env.LISA_OUTPUT_MODE?.trim().toLowerCase();
10
+ if (override === "interactive" || override === "concise") {
11
+ return override;
12
+ }
13
+
14
+ return undefined;
15
+ }
16
+
17
+ export function resolveOutputMode(
18
+ env: Record<string, string | undefined> = process.env,
19
+ stdout: { isTTY?: boolean } = process.stdout,
20
+ ): LisaOutputMode {
21
+ return resolveOverride(env) ?? (stdout.isTTY ? "interactive" : "concise");
22
+ }
23
+
24
+ export function getOutputMode(options: OutputModeOptions = {}): LisaOutputMode {
25
+ return resolveOutputMode(options.env ?? process.env, {
26
+ isTTY: options.isStdoutTTY ?? Boolean(process.stdout.isTTY),
27
+ });
28
+ }
29
+
30
+ export function isInteractiveOutput(options: OutputModeOptions = {}): boolean {
31
+ return getOutputMode(options) === "interactive";
32
+ }
@@ -0,0 +1,174 @@
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { dirname, isAbsolute, join, relative, resolve } from "path";
4
+
5
+ import type { HarnessSkillInstallDefinition, HarnessSkillInstallResult } from "../harness/types";
6
+
7
+ function resolveSkillsRoot(): string {
8
+ const candidates = [
9
+ join(import.meta.dir, "..", "..", "skills"),
10
+ join(import.meta.dir, "..", "skills"),
11
+ ];
12
+
13
+ return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
14
+ }
15
+
16
+ const SKILLS_ROOT = resolveSkillsRoot();
17
+
18
+ export function resolveSkillArtifactPath(harnessId: string, fileName: string): string {
19
+ return join(SKILLS_ROOT, harnessId, fileName);
20
+ }
21
+
22
+ function resolveConfiguredAbsolutePath(envVarName: string, fallbackPath: () => string): string {
23
+ const configured = process.env[envVarName]?.trim();
24
+ if (!configured) {
25
+ return fallbackPath();
26
+ }
27
+
28
+ if (!isAbsolute(configured)) {
29
+ throw new Error(`${envVarName} must be an absolute path.`);
30
+ }
31
+
32
+ return configured;
33
+ }
34
+
35
+ export function resolveHomeDirectory(): string {
36
+ return resolveConfiguredAbsolutePath("HOME", () => homedir());
37
+ }
38
+
39
+ export function resolveXdgConfigHome(): string {
40
+ return resolveConfiguredAbsolutePath("XDG_CONFIG_HOME", () => join(resolveHomeDirectory(), ".config"));
41
+ }
42
+
43
+ export function resolveXdgDataHome(): string {
44
+ return resolveConfiguredAbsolutePath("XDG_DATA_HOME", () => join(resolveHomeDirectory(), ".local", "share"));
45
+ }
46
+
47
+ export function resolveXdgStateHome(): string {
48
+ return resolveConfiguredAbsolutePath("XDG_STATE_HOME", () => join(resolveHomeDirectory(), ".local", "state"));
49
+ }
50
+
51
+ export function resolveCodexHome(): string {
52
+ return resolveConfiguredAbsolutePath("CODEX_HOME", () => join(resolveHomeDirectory(), ".codex"));
53
+ }
54
+
55
+ export function resolveGeminiHome(): string {
56
+ return join(resolveHomeDirectory(), ".gemini");
57
+ }
58
+
59
+ export function resolveLocalSkillWorkspace(cwd = process.cwd()): string {
60
+ return resolve(cwd);
61
+ }
62
+
63
+ export function renderOpenCodeSkill(content: string): string {
64
+ const trimmed = content.trimEnd();
65
+ return `---\nname: lisa\ndescription: Follow Lisa's checked-in repository guidance.\nlicense: MIT\ncompatibility: opencode\n---\n\n${trimmed}\n`;
66
+ }
67
+
68
+ function isLisaManagedSkill(content: string): boolean {
69
+ const trimmed = content.trimStart();
70
+ return trimmed.startsWith("# Lisa for ") || /^---\r?\nname:\s*lisa(?:\r?\n|$)/.test(trimmed);
71
+ }
72
+
73
+ function isPathInsideRoot(rootPath: string, targetPath: string): boolean {
74
+ const relativePath = relative(rootPath, targetPath);
75
+ return relativePath !== ".."
76
+ && !relativePath.startsWith("../")
77
+ && !relativePath.startsWith("..\\")
78
+ && !/^[A-Za-z]:[\\/]/.test(relativePath)
79
+ && !relativePath.startsWith("\\\\");
80
+ }
81
+
82
+ function assertSafeInstallPath(rootPath: string, destinationPath: string, scope: "local" | "global"): void {
83
+ const rootBoundary = resolve(rootPath);
84
+ if (!isPathInsideRoot(rootBoundary, resolve(destinationPath))) {
85
+ throw new Error(`${scope === "local" ? "Local" : "Global"} skill installs must stay inside the configured ${scope} root.`);
86
+ }
87
+
88
+ let existingRootBoundary = rootBoundary;
89
+ while (!existsSync(existingRootBoundary)) {
90
+ const parentPath = dirname(existingRootBoundary);
91
+ if (parentPath === existingRootBoundary) {
92
+ break;
93
+ }
94
+
95
+ existingRootBoundary = parentPath;
96
+ }
97
+ const rootRealPath = existsSync(existingRootBoundary) ? realpathSync(existingRootBoundary) : undefined;
98
+
99
+ let existingPath = resolve(destinationPath);
100
+ while (!existsSync(existingPath)) {
101
+ const parentPath = dirname(existingPath);
102
+ if (parentPath === existingPath) {
103
+ throw new Error(`Could not resolve a safe parent directory for ${destinationPath}`);
104
+ }
105
+
106
+ existingPath = parentPath;
107
+ }
108
+
109
+ let cursor = existingPath;
110
+ while (true) {
111
+ if (lstatSync(cursor).isSymbolicLink()) {
112
+ throw new Error(`Refusing to install a ${scope} skill through a symlinked path: ${cursor}`);
113
+ }
114
+
115
+ if (cursor === existingRootBoundary) {
116
+ break;
117
+ }
118
+
119
+ const parentPath = dirname(cursor);
120
+ if (parentPath === cursor) {
121
+ break;
122
+ }
123
+
124
+ cursor = parentPath;
125
+ }
126
+
127
+ if (!rootRealPath) {
128
+ return;
129
+ }
130
+
131
+ const existingRealPath = realpathSync(existingPath);
132
+ if (!isPathInsideRoot(rootRealPath, existingRealPath)) {
133
+ throw new Error(`${scope === "local" ? "Local" : "Global"} skill installs must stay inside the configured ${scope} root.`);
134
+ }
135
+ }
136
+
137
+ export function installSkillArtifact(
138
+ definition: HarnessSkillInstallDefinition,
139
+ content?: string,
140
+ options: { rootPath?: string; scope: "local" | "global" } = { scope: "global" },
141
+ ): HarnessSkillInstallResult {
142
+ if (!existsSync(definition.sourcePath)) {
143
+ throw new Error(`Skill artifact not found: ${definition.sourcePath}`);
144
+ }
145
+
146
+ const nextContent = content ?? readFileSync(definition.sourcePath, "utf8");
147
+
148
+ if (options.rootPath) {
149
+ assertSafeInstallPath(options.rootPath, definition.destinationPath, options.scope);
150
+ }
151
+
152
+ const existed = existsSync(definition.destinationPath);
153
+ if (existed) {
154
+ const currentContent = readFileSync(definition.destinationPath, "utf8");
155
+ if (currentContent === nextContent) {
156
+ return {
157
+ ...definition,
158
+ status: "unchanged",
159
+ };
160
+ }
161
+
162
+ if (!isLisaManagedSkill(currentContent)) {
163
+ throw new Error(`Refusing to overwrite non-Lisa guidance at ${definition.destinationPath}`);
164
+ }
165
+ }
166
+
167
+ mkdirSync(dirname(definition.destinationPath), { recursive: true });
168
+ writeFileSync(definition.destinationPath, nextContent);
169
+
170
+ return {
171
+ ...definition,
172
+ status: existed ? "updated" : "created",
173
+ };
174
+ }
@@ -0,0 +1,29 @@
1
+ import { runSkillInstallCommand } from "./install";
2
+
3
+ function printSkillHelp(): void {
4
+ console.log(`Lisa skill commands
5
+
6
+ Usage:
7
+ lisa skill install --local|--global
8
+
9
+ Available today:
10
+ install Install Lisa guidance for supported harnesses
11
+ `);
12
+ }
13
+
14
+ export async function runSkillCli(args: string[], cwd = process.cwd()): Promise<number> {
15
+ const [command] = args;
16
+
17
+ if (!command || command === "help" || command === "--help" || command === "-h") {
18
+ printSkillHelp();
19
+ return 0;
20
+ }
21
+
22
+ if (command === "install") {
23
+ return runSkillInstallCommand(args.slice(1), cwd);
24
+ }
25
+
26
+ console.error(`Unknown lisa skill command: ${command}`);
27
+ console.error("Run `lisa skill --help` for available commands.");
28
+ return 1;
29
+ }