runwork 0.10.4 → 0.12.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 (35) hide show
  1. package/dist/agents/__tests__/claude-code-managed-block.test.d.ts +1 -0
  2. package/dist/agents/__tests__/claude-code-managed-block.test.js +97 -0
  3. package/dist/agents/__tests__/codex-minimum-permissions.test.d.ts +1 -0
  4. package/dist/agents/__tests__/codex-minimum-permissions.test.js +82 -0
  5. package/dist/agents/__tests__/cursor-merge.test.d.ts +1 -0
  6. package/dist/agents/__tests__/cursor-merge.test.js +66 -0
  7. package/dist/agents/__tests__/defaults-merge.test.d.ts +1 -0
  8. package/dist/agents/__tests__/defaults-merge.test.js +268 -0
  9. package/dist/agents/__tests__/intro-skill.test.js +32 -0
  10. package/dist/agents/claude-code.d.ts +5 -0
  11. package/dist/agents/claude-code.js +29 -0
  12. package/dist/agents/cursor.d.ts +23 -1
  13. package/dist/agents/cursor.js +42 -3
  14. package/dist/agents/default-config.d.ts +30 -0
  15. package/dist/agents/default-config.js +67 -0
  16. package/dist/agents/defaults-merge.d.ts +72 -0
  17. package/dist/agents/defaults-merge.js +131 -0
  18. package/dist/agents/intro-skill.d.ts +9 -0
  19. package/dist/agents/intro-skill.js +41 -0
  20. package/dist/agents/types.d.ts +31 -2
  21. package/dist/commands/__tests__/setup-persona.test.d.ts +1 -0
  22. package/dist/commands/__tests__/setup-persona.test.js +31 -0
  23. package/dist/commands/info.d.ts +1 -1
  24. package/dist/commands/info.js +7 -2
  25. package/dist/commands/setup.d.ts +7 -0
  26. package/dist/commands/setup.js +22 -0
  27. package/dist/commands/sync.js +147 -59
  28. package/dist/generated/bundled-types.js +33 -33
  29. package/dist/generated/version.d.ts +1 -1
  30. package/dist/generated/version.js +1 -1
  31. package/dist/types.d.ts +36 -0
  32. package/dist/ui/banner.js +1 -1
  33. package/dist/utils/app-info.d.ts +4 -0
  34. package/dist/utils/app-info.js +17 -0
  35. package/package.json +1 -1
@@ -5,6 +5,39 @@ import { execFileSync } from '../utils/subprocess.js';
5
5
  import { querySqlite, openWritableSqlite } from '../utils/sqlite.js';
6
6
  import { whichBinary } from '../utils/which.js';
7
7
  import { mergeJsonMcpServers, removeRunworkMcpServers, readJsonConfig, writeJsonConfig } from './utils/json-config.js';
8
+ /**
9
+ * Merge a new managed list into a markerless on-disk array while preserving
10
+ * user-added entries.
11
+ *
12
+ * existing = what's currently on disk
13
+ * baseline = what we wrote on the previous sync (state.lastInjected). Items
14
+ * present here are assumed to be ours; subtracting them from
15
+ * existing leaves entries the user added themselves.
16
+ * incoming = the new managed set to inject (defaults already filtered for
17
+ * opt-outs, merged with team rules)
18
+ *
19
+ * If baseline is empty (bootstrap or first run with this CLI version), we
20
+ * assume nothing currently on disk is ours and preserve all existing entries.
21
+ */
22
+ export function mergeMarkerless(existing, baseline, incoming) {
23
+ const baselineSet = new Set(baseline);
24
+ const userEntries = existing.filter((item) => !baselineSet.has(item));
25
+ const seen = new Set();
26
+ const result = [];
27
+ for (const item of userEntries) {
28
+ if (!seen.has(item)) {
29
+ seen.add(item);
30
+ result.push(item);
31
+ }
32
+ }
33
+ for (const item of incoming) {
34
+ if (!seen.has(item)) {
35
+ seen.add(item);
36
+ result.push(item);
37
+ }
38
+ }
39
+ return result;
40
+ }
8
41
  export class CursorAdapter {
9
42
  name = 'Cursor';
10
43
  slug = 'cursor';
@@ -68,7 +101,7 @@ ${skill.content}`;
68
101
  writeFileSync(filePath, mdcContent);
69
102
  }
70
103
  // ── Agent config (model, permissions via SQLite) ────────────────────
71
- async writeAgentConfig(config, scope) {
104
+ async writeAgentConfig(config, scope, baseline) {
72
105
  if (scope !== 'user')
73
106
  return;
74
107
  // Sandbox network allowlist: merge into ~/.cursor/cli-config.json
@@ -108,10 +141,10 @@ ${skill.content}`;
108
141
  }
109
142
  if (config.permissionRules && data.composerState) {
110
143
  if (config.permissionRules.allow) {
111
- data.composerState.yoloCommandAllowlist = config.permissionRules.allow;
144
+ data.composerState.yoloCommandAllowlist = mergeMarkerless(data.composerState.yoloCommandAllowlist ?? [], baseline?.allow ?? [], config.permissionRules.allow);
112
145
  }
113
146
  if (config.permissionRules.deny) {
114
- data.composerState.yoloCommandDenylist = config.permissionRules.deny;
147
+ data.composerState.yoloCommandDenylist = mergeMarkerless(data.composerState.yoloCommandDenylist ?? [], baseline?.deny ?? [], config.permissionRules.deny);
115
148
  }
116
149
  }
117
150
  try {
@@ -125,6 +158,12 @@ ${skill.content}`;
125
158
  db.close();
126
159
  }
127
160
  }
161
+ async readManagedBlock(_scope) {
162
+ // Cursor's yoloCommandAllowlist has no marker — there's no way to read a
163
+ // managed-subset from disk. The sync loop relies on the `baseline` arg
164
+ // (state.lastInjected) passed into writeAgentConfig instead.
165
+ return undefined;
166
+ }
128
167
  /**
129
168
  * Merge domains into Cursor's sandbox network policy (~/.cursor/sandbox.json).
130
169
  * Schema per https://cursor.com/docs/reference/sandbox:
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Baked-in default permission rules that `runwork sync` injects into each
3
+ * supported agent's configuration.
4
+ *
5
+ * Defaults are user-scope only. They merge with team-managed rules from the
6
+ * workspace admin and are tracked in `~/.runwork/setup.json` under
7
+ * `agentDefaults[slug]` so that:
8
+ * 1. Removals stick: if a user deletes an injected entry, it never returns.
9
+ * 2. Newly added defaults flow in: CLI upgrades that ship more defaults
10
+ * auto-apply on the next sync.
11
+ * 3. Removed defaults disappear: shrinking the set here also removes the
12
+ * entries from users' on-disk config on next sync.
13
+ * 4. Tool removal resets state: `runwork agents remove <slug>` clears the
14
+ * per-agent state, so re-adding the tool starts from a clean bootstrap.
15
+ *
16
+ * Only agents whose permission model maps cleanly to command-pattern allow/deny
17
+ * lists appear here. Codex, Gemini, Cline, and Claude Desktop are intentionally
18
+ * absent (their config formats are toggle-based or tool-name-based and do not
19
+ * fit). Network domain allowlisting for those agents is handled separately via
20
+ * AgentConfigOverride.networkAllowlist in sync.ts.
21
+ */
22
+ export interface BakedDefaults {
23
+ allow: string[];
24
+ deny: string[];
25
+ }
26
+ export declare const RUNWORK_AGENT_DEFAULTS: Record<string, BakedDefaults>;
27
+ /** Schema version recorded in setup.json. Bump when default-injection semantics change. */
28
+ export declare const AGENT_DEFAULTS_SCHEMA_VERSION = 1;
29
+ /** Slugs that have baked-in defaults. */
30
+ export declare function hasBakedDefaults(slug: string): boolean;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Baked-in default permission rules that `runwork sync` injects into each
3
+ * supported agent's configuration.
4
+ *
5
+ * Defaults are user-scope only. They merge with team-managed rules from the
6
+ * workspace admin and are tracked in `~/.runwork/setup.json` under
7
+ * `agentDefaults[slug]` so that:
8
+ * 1. Removals stick: if a user deletes an injected entry, it never returns.
9
+ * 2. Newly added defaults flow in: CLI upgrades that ship more defaults
10
+ * auto-apply on the next sync.
11
+ * 3. Removed defaults disappear: shrinking the set here also removes the
12
+ * entries from users' on-disk config on next sync.
13
+ * 4. Tool removal resets state: `runwork agents remove <slug>` clears the
14
+ * per-agent state, so re-adding the tool starts from a clean bootstrap.
15
+ *
16
+ * Only agents whose permission model maps cleanly to command-pattern allow/deny
17
+ * lists appear here. Codex, Gemini, Cline, and Claude Desktop are intentionally
18
+ * absent (their config formats are toggle-based or tool-name-based and do not
19
+ * fit). Network domain allowlisting for those agents is handled separately via
20
+ * AgentConfigOverride.networkAllowlist in sync.ts.
21
+ */
22
+ export const RUNWORK_AGENT_DEFAULTS = {
23
+ 'claude-code': {
24
+ allow: [
25
+ 'Bash(runwork *)',
26
+ 'Bash(runwork-cli *)',
27
+ 'Bash(git status)',
28
+ 'Bash(git diff:*)',
29
+ 'Bash(git log:*)',
30
+ 'Bash(git branch:*)',
31
+ 'Bash(git show:*)',
32
+ 'Bash(ls:*)',
33
+ 'Bash(pwd)',
34
+ 'Bash(cat:*)',
35
+ ],
36
+ deny: [
37
+ 'Bash(rm -rf *)',
38
+ 'Bash(rm -rf /*)',
39
+ 'Bash(sudo rm *)',
40
+ ],
41
+ },
42
+ 'cursor': {
43
+ allow: [
44
+ 'runwork',
45
+ 'runwork *',
46
+ 'runwork-cli',
47
+ 'runwork-cli *',
48
+ 'git status',
49
+ 'git diff',
50
+ 'git log',
51
+ 'git branch',
52
+ 'ls',
53
+ 'pwd',
54
+ 'cat',
55
+ ],
56
+ deny: [
57
+ 'rm -rf *',
58
+ 'sudo *',
59
+ ],
60
+ },
61
+ };
62
+ /** Schema version recorded in setup.json. Bump when default-injection semantics change. */
63
+ export const AGENT_DEFAULTS_SCHEMA_VERSION = 1;
64
+ /** Slugs that have baked-in defaults. */
65
+ export function hasBakedDefaults(slug) {
66
+ return slug in RUNWORK_AGENT_DEFAULTS;
67
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Pure functions for the default-rules merge algorithm.
3
+ *
4
+ * The sync loop (commands/sync.ts) wires these together with per-adapter I/O.
5
+ * Keeping the logic side-effect-free here makes it cheap to unit-test the
6
+ * tricky lifecycle (bootstrap, opt-out detection, set shrinking) without
7
+ * mocking filesystem layers.
8
+ */
9
+ import type { AgentDefaultsState } from '../types.js';
10
+ import type { BakedDefaults } from './default-config.js';
11
+ /** Items present in `lastInjected` but not in `onDisk`. These are entries the user removed since the previous sync. */
12
+ export declare function detectRemovals(lastInjected: string[], onDisk: string[]): string[];
13
+ /** Defaults filtered to exclude anything the user has opted out of. */
14
+ export declare function computeApplicable(baked: string[], optOuts: string[]): string[];
15
+ /**
16
+ * Dedupe-silently merge of defaults and team rules. Team rules appear after
17
+ * defaults in the output (the marker block's team-managed half), and dedupe
18
+ * keeps the team-positioned copy when a default and a team rule are identical
19
+ * strings. Stable ordering is preserved.
20
+ */
21
+ export declare function mergeAllow(applicableDefaults: string[], teamRules: string[]): string[];
22
+ export declare function unique(items: string[]): string[];
23
+ /** True if no per-agent state has ever been recorded — first sync after upgrade or fresh install. */
24
+ export declare function isBootstrap(state: AgentDefaultsState | undefined): boolean;
25
+ export interface ResolveResult {
26
+ /**
27
+ * Final merged list to write into the managed block.
28
+ * For marker-based agents (Claude Code): goes after the marker.
29
+ * For markerless agents (Cursor): becomes part of the array,
30
+ * with `baseline` used by the adapter to identify and preserve user entries.
31
+ */
32
+ managedAllow: string[];
33
+ managedDeny: string[];
34
+ /** Defaults that we actually injected this sync. Stored as next sync's lastInjected. */
35
+ applicableAllow: string[];
36
+ applicableDeny: string[];
37
+ /** New sticky opt-out set. Stored as next sync's userOptOuts. */
38
+ newOptOutsAllow: string[];
39
+ newOptOutsDeny: string[];
40
+ /** Diagnostic counts for the sync log. */
41
+ removalsThisSync: {
42
+ allow: number;
43
+ deny: number;
44
+ };
45
+ /** True when this sync is the bootstrap pass and skipped default injection. */
46
+ bootstrapped: boolean;
47
+ }
48
+ export interface ResolveInput {
49
+ baked: BakedDefaults | undefined;
50
+ state: AgentDefaultsState | undefined;
51
+ onDisk: {
52
+ allow?: string[];
53
+ deny?: string[];
54
+ } | undefined;
55
+ team: {
56
+ allow?: string[];
57
+ deny?: string[];
58
+ } | undefined;
59
+ }
60
+ /**
61
+ * Single-call resolver for one agent. Consumes the full input set and emits
62
+ * the merged managed lists plus the next-sync state values. Pure: no I/O.
63
+ *
64
+ * Cases:
65
+ * 1. No baked defaults for this agent → emits team rules only (no defaults
66
+ * logic kicks in). State stays undefined.
67
+ * 2. Bootstrap (state undefined) → applies team rules but does NOT inject
68
+ * defaults this sync. Caller initializes empty state for next time.
69
+ * 3. Normal → detects removals against lastInjected, accumulates opt-outs,
70
+ * filters baked by opt-outs, merges with team rules.
71
+ */
72
+ export declare function resolveAgentDefaults(input: ResolveInput): ResolveResult;
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Pure functions for the default-rules merge algorithm.
3
+ *
4
+ * The sync loop (commands/sync.ts) wires these together with per-adapter I/O.
5
+ * Keeping the logic side-effect-free here makes it cheap to unit-test the
6
+ * tricky lifecycle (bootstrap, opt-out detection, set shrinking) without
7
+ * mocking filesystem layers.
8
+ */
9
+ /** Items present in `lastInjected` but not in `onDisk`. These are entries the user removed since the previous sync. */
10
+ export function detectRemovals(lastInjected, onDisk) {
11
+ const onDiskSet = new Set(onDisk);
12
+ return lastInjected.filter((item) => !onDiskSet.has(item));
13
+ }
14
+ /** Defaults filtered to exclude anything the user has opted out of. */
15
+ export function computeApplicable(baked, optOuts) {
16
+ const optOutSet = new Set(optOuts);
17
+ return baked.filter((item) => !optOutSet.has(item));
18
+ }
19
+ /**
20
+ * Dedupe-silently merge of defaults and team rules. Team rules appear after
21
+ * defaults in the output (the marker block's team-managed half), and dedupe
22
+ * keeps the team-positioned copy when a default and a team rule are identical
23
+ * strings. Stable ordering is preserved.
24
+ */
25
+ export function mergeAllow(applicableDefaults, teamRules) {
26
+ const teamSet = new Set(teamRules);
27
+ const result = [];
28
+ for (const item of applicableDefaults) {
29
+ if (!teamSet.has(item))
30
+ result.push(item);
31
+ }
32
+ for (const item of teamRules) {
33
+ result.push(item);
34
+ }
35
+ return result;
36
+ }
37
+ export function unique(items) {
38
+ const seen = new Set();
39
+ const out = [];
40
+ for (const item of items) {
41
+ if (!seen.has(item)) {
42
+ seen.add(item);
43
+ out.push(item);
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+ /** True if no per-agent state has ever been recorded — first sync after upgrade or fresh install. */
49
+ export function isBootstrap(state) {
50
+ return state === undefined;
51
+ }
52
+ /**
53
+ * Single-call resolver for one agent. Consumes the full input set and emits
54
+ * the merged managed lists plus the next-sync state values. Pure: no I/O.
55
+ *
56
+ * Cases:
57
+ * 1. No baked defaults for this agent → emits team rules only (no defaults
58
+ * logic kicks in). State stays undefined.
59
+ * 2. Bootstrap (state undefined) → applies team rules but does NOT inject
60
+ * defaults this sync. Caller initializes empty state for next time.
61
+ * 3. Normal → detects removals against lastInjected, accumulates opt-outs,
62
+ * filters baked by opt-outs, merges with team rules.
63
+ */
64
+ export function resolveAgentDefaults(input) {
65
+ const team = input.team ?? {};
66
+ const teamAllow = team.allow ?? [];
67
+ const teamDeny = team.deny ?? [];
68
+ // Case 1: no baked defaults — emit team rules unchanged, no state to track.
69
+ if (!input.baked) {
70
+ return {
71
+ managedAllow: unique(teamAllow),
72
+ managedDeny: unique(teamDeny),
73
+ applicableAllow: [],
74
+ applicableDeny: [],
75
+ newOptOutsAllow: [],
76
+ newOptOutsDeny: [],
77
+ removalsThisSync: { allow: 0, deny: 0 },
78
+ bootstrapped: false,
79
+ };
80
+ }
81
+ // Case 2: bootstrap — do not inject defaults this pass.
82
+ if (isBootstrap(input.state)) {
83
+ return {
84
+ managedAllow: unique(teamAllow),
85
+ managedDeny: unique(teamDeny),
86
+ applicableAllow: [],
87
+ applicableDeny: [],
88
+ newOptOutsAllow: [],
89
+ newOptOutsDeny: [],
90
+ removalsThisSync: { allow: 0, deny: 0 },
91
+ bootstrapped: true,
92
+ };
93
+ }
94
+ // Case 3: normal merge.
95
+ const state = input.state;
96
+ const lastInjectedAllow = state.lastInjected.allow ?? [];
97
+ const lastInjectedDeny = state.lastInjected.deny ?? [];
98
+ const existingOptOutsAllow = state.userOptOuts.allow ?? [];
99
+ const existingOptOutsDeny = state.userOptOuts.deny ?? [];
100
+ // Removal detection requires a reliable on-disk read. Markerless adapters
101
+ // (Cursor) cannot distinguish injected entries from user entries on disk, so
102
+ // they return `undefined` from readManagedBlock. Treating `undefined` as an
103
+ // empty array would fabricate a "user removed everything" signal on every
104
+ // subsequent sync and incorrectly convert every default into a sticky opt-out.
105
+ // When we cannot read managed state, we trust the baseline mechanism in the
106
+ // adapter and skip removal detection for this sync.
107
+ const canDetectRemovals = input.onDisk !== undefined;
108
+ const newRemovalsAllow = canDetectRemovals
109
+ ? detectRemovals(lastInjectedAllow, input.onDisk?.allow ?? [])
110
+ : [];
111
+ const newRemovalsDeny = canDetectRemovals
112
+ ? detectRemovals(lastInjectedDeny, input.onDisk?.deny ?? [])
113
+ : [];
114
+ const newOptOutsAllow = unique([...existingOptOutsAllow, ...newRemovalsAllow]);
115
+ const newOptOutsDeny = unique([...existingOptOutsDeny, ...newRemovalsDeny]);
116
+ const applicableAllow = computeApplicable(input.baked.allow, newOptOutsAllow);
117
+ const applicableDeny = computeApplicable(input.baked.deny, newOptOutsDeny);
118
+ return {
119
+ managedAllow: mergeAllow(applicableAllow, teamAllow),
120
+ managedDeny: mergeAllow(applicableDeny, teamDeny),
121
+ applicableAllow,
122
+ applicableDeny,
123
+ newOptOutsAllow,
124
+ newOptOutsDeny,
125
+ removalsThisSync: {
126
+ allow: newRemovalsAllow.length,
127
+ deny: newRemovalsDeny.length,
128
+ },
129
+ bootstrapped: false,
130
+ };
131
+ }
@@ -22,6 +22,15 @@ export interface InstructionHintContext {
22
22
  appCount: number;
23
23
  skillCount: number;
24
24
  mcpServerCount: number;
25
+ /**
26
+ * Technical-level classification from desktop onboarding. When present,
27
+ * a persona-specific communication block is added to the instruction hint
28
+ * so integrated agents match the user's experience level.
29
+ */
30
+ persona?: {
31
+ level: 1 | 2 | 3;
32
+ label: 'novice' | 'curious' | 'engineer';
33
+ };
25
34
  }
26
35
  export declare function buildAppSkillDescription(appName: string, registries: WorkspaceAllData | null): string;
27
36
  /**
@@ -1,3 +1,37 @@
1
+ /**
2
+ * Build a persona-specific communication block for the instruction hint.
3
+ * Returns an empty array for engineers (level 3), who need no special tone
4
+ * handling, and for an absent/unknown persona.
5
+ */
6
+ function buildPersonaBlock(persona) {
7
+ if (!persona || persona.level === 3)
8
+ return [];
9
+ if (persona.level === 1) {
10
+ return [
11
+ '',
12
+ '### Communicating with this user',
13
+ '',
14
+ 'The person you are helping is new to AI tools and is not a software developer. Adapt how you work with them:',
15
+ '',
16
+ '- Explain things in plain, everyday language. Do not assume they know programming or technical concepts.',
17
+ '- Do not ask them to run developer tooling (npm, bun, node, pnpm, yarn, git, build or package-manager commands). When a task needs technical steps, perform those steps yourself instead of instructing the user.',
18
+ '- Avoid jargon. When a technical term is unavoidable, define it in one short sentence.',
19
+ '- Work in small steps. Do one thing, confirm it landed, then continue.',
20
+ '- Focus on what the user wants to accomplish, not on the implementation details.',
21
+ ];
22
+ }
23
+ // Level 2: curious
24
+ return [
25
+ '',
26
+ '### Communicating with this user',
27
+ '',
28
+ 'The person you are helping has some familiarity with AI tools but is not a professional developer. Adapt how you work with them:',
29
+ '',
30
+ '- Prefer plain language. When you use a technical term, briefly explain what it means.',
31
+ '- Do not lean on developer tooling (npm, bun, node, git, build commands) unless it is genuinely required, and explain what any command does before suggesting it.',
32
+ '- Keep explanations concise and outcome-focused.',
33
+ ];
34
+ }
1
35
  /** Format a list of names with truncation. Shows up to `max` items, then "+ N more". */
2
36
  function formatList(items, max = 8) {
3
37
  if (items.length <= max)
@@ -314,6 +348,12 @@ export function generateIntroSkill(ctx) {
314
348
  lines.push('- Every workflow needs a trigger (endpoint, schedule, or UI button).');
315
349
  lines.push('- Use `search_available_integrations` MCP tool or `runwork integrations search <query>` before adding integrations, never guess IDs.');
316
350
  lines.push('');
351
+ lines.push('**Environment variables and secrets:** Runwork apps do NOT have a custom environment variable or secrets system.');
352
+ lines.push('- You cannot add your own keys to a `.env` file, define `process.env.MY_KEY`, or configure per-app secrets. Do not write code that depends on custom env vars, and do not tell the user to create `.env` files or set secrets.');
353
+ lines.push('- A fixed set of platform variables is injected automatically by `@runworkai/framework` (workspace identifiers, AI proxy, integration proxy). You do not set these and should not hardcode or depend on their exact names.');
354
+ lines.push('- For configuration values or API keys you need to store, use a dedicated entity with an app-specific name (for example `AcmeCrmConfig`, not a generic `Config` or `AppConfig`, which would collide with other apps in the workspace) and read it at runtime, not env vars.');
355
+ lines.push('- For third-party API access, use connected Runwork integrations (`{id}_api` MCP tools); they handle authentication without secrets in your code.');
356
+ lines.push('');
317
357
  // Capability reference — detailed mapping of needs to Runwork capabilities.
318
358
  // The "Default Actions" section at the top handles routing; this is the
319
359
  // longer reference for when the agent wants to understand what's available.
@@ -399,6 +439,7 @@ export function generateInstructionHint(ctx) {
399
439
  `**Available now:** ${inventory}`,
400
440
  '',
401
441
  `Runwork MCP tools are always connected -- use them for data access, integration API calls, skill management, and resource discovery.${integrationToolHint} Use \`runwork\` CLI to create (\`runwork init\`), develop (\`runwork dev\`), and deploy (\`runwork deploy\`) apps. Install CLI: \`curl -fsSL https://runwork.ai/install.sh | sh\`. Web dashboard: ${dashboardUrl}. Invoke the \`runwork\` skill for full capability reference.`,
442
+ ...buildPersonaBlock(ctx.persona),
402
443
  '<!-- runwork:end -->',
403
444
  ].join('\n');
404
445
  }
@@ -33,8 +33,37 @@ export interface AgentAdapter {
33
33
  mcpProvidesSkills?: boolean;
34
34
  /** Write team-managed instructions (separate marker block from auto-generated hint) */
35
35
  writeTeamInstructions?(instructions: string, scope: 'project' | 'user'): Promise<void>;
36
- /** Write agent-specific config overrides from workspace admin (model, permissions) */
37
- writeAgentConfig?(config: AgentConfigOverride, scope: 'project' | 'user'): Promise<void>;
36
+ /**
37
+ * Write agent-specific config overrides from workspace admin (model, permissions).
38
+ *
39
+ * @param config - The full resolved config to apply this sync (already merged
40
+ * with baked defaults and network/minimum settings by the sync loop).
41
+ * @param scope - Project- or user-scope configuration target.
42
+ * @param baseline - Optional. The set of managed entries the previous sync
43
+ * wrote (lastInjected from setup.json). Adapters that lack on-disk markers
44
+ * to distinguish runwork-managed entries from user-added entries (e.g.
45
+ * Cursor's yoloCommandAllowlist) use this to subtract our previous
46
+ * contribution before re-injecting, preserving user edits. Marker-based
47
+ * adapters (Claude Code) can ignore this parameter.
48
+ */
49
+ writeAgentConfig?(config: AgentConfigOverride, scope: 'project' | 'user', baseline?: {
50
+ allow?: string[];
51
+ deny?: string[];
52
+ }): Promise<void>;
53
+ /**
54
+ * Read the currently-injected managed-block entries for this scope.
55
+ * Used by sync to detect when the user has removed an entry that we
56
+ * previously injected (so we can record it as a sticky opt-out).
57
+ *
58
+ * For marker-based adapters (Claude Code): returns items after the marker.
59
+ * For markerless adapters (Cursor): may return undefined; the sync loop
60
+ * falls back to the `baseline` mechanism (state.lastInjected) for those.
61
+ * Optional — adapters without a permission file at all (Cline) skip this.
62
+ */
63
+ readManagedBlock?(scope: 'project' | 'user'): Promise<{
64
+ allow?: string[];
65
+ deny?: string[];
66
+ } | undefined>;
38
67
  /** Remove all Runwork-managed config, skills, MCP entries, and instruction hints.
39
68
  * The manifest lists exactly which resources were synced so only those are removed. */
40
69
  cleanup?(scope: 'project' | 'user', manifest?: CleanupManifest): Promise<void>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,31 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { resolvePersona } from '../setup.js';
3
+ /**
4
+ * resolvePersona decides what persona ends up in setup.json. The `--persona`
5
+ * flag (set by the desktop onboarding flow) wins; otherwise an existing persona
6
+ * on disk is preserved so a manual `runwork setup` re-run does not wipe it.
7
+ */
8
+ describe('resolvePersona', () => {
9
+ it('maps a valid --persona flag to level and label', () => {
10
+ expect(resolvePersona('1', undefined)).toEqual({ level: 1, label: 'novice' });
11
+ expect(resolvePersona('2', undefined)).toEqual({ level: 2, label: 'curious' });
12
+ expect(resolvePersona('3', undefined)).toEqual({ level: 3, label: 'engineer' });
13
+ });
14
+ it('preserves the existing persona when no flag is given', () => {
15
+ const existing = { level: 2, label: 'curious' };
16
+ expect(resolvePersona(undefined, existing)).toEqual(existing);
17
+ });
18
+ it('falls back to the existing persona for an invalid flag', () => {
19
+ const existing = { level: 3, label: 'engineer' };
20
+ expect(resolvePersona('99', existing)).toEqual(existing);
21
+ expect(resolvePersona('abc', existing)).toEqual(existing);
22
+ expect(resolvePersona('0', existing)).toEqual(existing);
23
+ });
24
+ it('returns undefined when there is no flag and nothing on disk', () => {
25
+ expect(resolvePersona(undefined, undefined)).toBeUndefined();
26
+ });
27
+ it('prefers the flag over an existing on-disk persona', () => {
28
+ const existing = { level: 1, label: 'novice' };
29
+ expect(resolvePersona('3', existing)).toEqual({ level: 3, label: 'engineer' });
30
+ });
31
+ });
@@ -121,7 +121,7 @@ interface InfoOutput {
121
121
  */
122
122
  localDevSession: LocalDevState;
123
123
  production: {
124
- url: null;
124
+ url: string | null;
125
125
  deployed: boolean;
126
126
  };
127
127
  integrations: Array<{
@@ -239,7 +239,12 @@ function printHumanOutput(data) {
239
239
  else {
240
240
  console.log(` ${dim(pad('Preview:'))}${dim('(not running)')}`);
241
241
  }
242
- console.log(` ${dim(pad('Production:'))}${dim('(not available)')}`);
242
+ if (data.production.url) {
243
+ console.log(` ${dim(pad('Production:'))}${green(data.production.url)} ${dim('(deployed)')}`);
244
+ }
245
+ else {
246
+ console.log(` ${dim(pad('Production:'))}${dim('(not deployed)')}`);
247
+ }
243
248
  // Local dev session view: what's running on THIS machine, from the
244
249
  // session file. This may disagree with the server's "preview"
245
250
  // status above; that's diagnostic, not a bug.
@@ -422,7 +427,7 @@ export const infoCommand = new Command('info')
422
427
  },
423
428
  preview,
424
429
  localDevSession,
425
- production: { url: null, deployed: false },
430
+ production: appInfo?.production ?? { url: null, deployed: false },
426
431
  integrations,
427
432
  registries,
428
433
  cli: {
@@ -1,5 +1,12 @@
1
1
  import { Command } from 'commander';
2
2
  import { ApiClient } from '../api/client.js';
3
+ import type { SetupState } from '../types.js';
4
+ /**
5
+ * Resolve the persona to persist in setup.json. Prefers the `--persona` flag
6
+ * (passed by the desktop onboarding flow); otherwise preserves any persona
7
+ * already on disk so re-running `setup` without the flag does not wipe it.
8
+ */
9
+ export declare function resolvePersona(flag: string | undefined, existing: SetupState['persona']): SetupState['persona'];
3
10
  /**
4
11
  * Resolve the workspace for `setup` and persist it as the default in credentials.
5
12
  *
@@ -8,6 +8,26 @@ import { resolveWorkspace, hasProjectConfig } from '../workspace/resolve.js';
8
8
  import { promptSelect, promptConfirm } from '../utils/prompt.js';
9
9
  import { detectAgents, printNoAgentsMessage } from '../agents/detect.js';
10
10
  import { syncFromState } from './sync.js';
11
+ import { loadSetupState } from '../utils/setup-state.js';
12
+ const PERSONA_LABELS = {
13
+ 1: 'novice',
14
+ 2: 'curious',
15
+ 3: 'engineer',
16
+ };
17
+ /**
18
+ * Resolve the persona to persist in setup.json. Prefers the `--persona` flag
19
+ * (passed by the desktop onboarding flow); otherwise preserves any persona
20
+ * already on disk so re-running `setup` without the flag does not wipe it.
21
+ */
22
+ export function resolvePersona(flag, existing) {
23
+ if (flag) {
24
+ const level = Number(flag);
25
+ if (level === 1 || level === 2 || level === 3) {
26
+ return { level, label: PERSONA_LABELS[level] };
27
+ }
28
+ }
29
+ return existing;
30
+ }
11
31
  /**
12
32
  * Resolve the workspace for `setup` and persist it as the default in credentials.
13
33
  *
@@ -49,6 +69,7 @@ export const setupCommand = new Command('setup')
49
69
  .option('--agent <slug>', 'Only configure a specific agent (e.g. claude-code, cursor)')
50
70
  .option('--dry-run', 'Show what would be configured without writing files')
51
71
  .option('-y, --yes', 'Skip all prompts, configure all detected agents with user scope')
72
+ .option('--persona <level>', 'Technical-level persona for agent instructions (1=novice, 2=curious, 3=engineer)')
52
73
  .action(async (opts) => {
53
74
  const credentials = requireAuth();
54
75
  const client = new ApiClient(credentials);
@@ -124,6 +145,7 @@ export const setupCommand = new Command('setup')
124
145
  skills: [],
125
146
  skillHashes: {},
126
147
  lastDetectedAt: new Date().toISOString(),
148
+ persona: resolvePersona(opts.persona, loadSetupState()?.persona),
127
149
  };
128
150
  const scopes = scope === 'both' ? ['project', 'user'] : [scope];
129
151
  for (const s of scopes) {