@@ -42,6 +42,7 @@ func newTraceInitCmd() *cobra.Command {
4242 var (
4343 project string
4444 contract string
45+ localFlag bool
4546 claudeFlag bool
4647 cursorFlag bool
4748 opencodeFlag bool
@@ -71,7 +72,14 @@ required unless .chainloop.yml carries projectName. Set CHAINLOOP_NO_PROMPT to
7172turn the questions off on a terminal too.
7273
7374The organization, project, workflow and require-trace values are saved to
74- .chainloop.yml, and every push reads them from there.` ,
75+ .chainloop.yml, and every push reads them from there.
76+
77+ With --local, the repository is set up without a control plane: it installs the
78+ same hooks but creates no workflow, needs no login, and asks for no organization
79+ or project. The pre-push hook then pushes no attestation. Sessions are recorded
80+ as usual, and their evidence is written to disk with "chainloop trace export".
81+ Local mode is per-clone: it is marked in the local trace state, not in the shared
82+ .chainloop.yml, so it never turns off attestation for teammates.` ,
7583 RunE : func (cmd * cobra.Command , _ []string ) error {
7684 gitDir , repoRoot , err := tracegit .FindGitDirAndRoot ()
7785 if err != nil {
@@ -83,14 +91,31 @@ The organization, project, workflow and require-trace values are saved to
8391 return err
8492 }
8593
94+ // Local mode creates no workflow and pushes no attestation, so the
95+ // flags that only shape those are contradictions rather than extra
96+ // configuration. Reject them instead of ignoring them silently.
97+ if localFlag {
98+ if contract != "" {
99+ return fmt .Errorf ("--contract cannot be used with --local: local mode creates no workflow and contacts no control plane" )
100+ }
101+ if cmd .Flags ().Changed ("require-trace" ) {
102+ return fmt .Errorf ("--require-trace cannot be used with --local: local mode pushes no attestation, so there is nothing to require" )
103+ }
104+ }
105+
86106 // Fill in whatever the flags and .chainloop.yml did not settle,
87107 // asking the user when there is one to ask. The executor it hands
88108 // back is pinned to the resolved organization and reused below.
89- executor , err := resolveTraceIdentity (cmd .Context (), cfg , repoRoot )
90- if err != nil {
91- return stopIfAborted (err )
109+ // Local mode talks to no control plane, so it resolves no identity
110+ // and opens no executor.
111+ var executor * action.AttestationExecutor
112+ if ! localFlag {
113+ executor , err = resolveTraceIdentity (cmd .Context (), cfg , repoRoot )
114+ if err != nil {
115+ return stopIfAborted (err )
116+ }
117+ defer func () { _ = executor .Close () }()
92118 }
93- defer func () { _ = executor .Close () }()
94119
95120 // Ask which agents to trace before anything is created or written,
96121 // so every question is answered up front and an abort leaves the
@@ -107,8 +132,12 @@ The organization, project, workflow and require-trace values are saved to
107132 return fmt .Errorf ("no trace providers selected" )
108133 }
109134
110- if err := ensureTraceInitWorkflow (cmd .Context (), executor , cfg , contract ); err != nil {
111- return err
135+ // Local mode has no control plane to create the workflow in; the
136+ // evidence is exported to disk rather than attested to a workflow.
137+ if ! localFlag {
138+ if err := ensureTraceInitWorkflow (cmd .Context (), executor , cfg , contract ); err != nil {
139+ return err
140+ }
112141 }
113142
114143 // The harness hooks come first of everything that is left behind.
@@ -171,7 +200,7 @@ The organization, project, workflow and require-trace values are saved to
171200 // local, but a failure part-way through it would leave a repository
172201 // half set up: harness hooks calling a configuration that is not
173202 // there, or a configuration with nothing recording into it.
174- if err := writeTraceInitState (cfg , repoRoot , gitDir ); err != nil {
203+ if err := writeTraceInitState (cfg , repoRoot , gitDir , localFlag ); err != nil {
175204 for _ , snapshot := range before {
176205 if rerr := snapshot .restore (); rerr != nil {
177206 logger .Debug ().Err (rerr ).Str ("path" , snapshot .path ).
@@ -189,13 +218,20 @@ The organization, project, workflow and require-trace values are saved to
189218 workDir = repoRoot
190219 }
191220
221+ // What the run produced, and what to do with it. Every step above logs
222+ // at debug, so this is what the user is left with.
223+ if localFlag {
224+ writeTraceInitLocalSummary (os .Stdout , providerNames (installed ))
225+ writeTraceLocalNextSteps (os .Stdout , repoRoot , workDir , installed )
226+
227+ return nil
228+ }
229+
192230 // Nothing pinned an organization when cfg has none, so the workflow
193231 // went to the one the CLI points at. That is the one to report: it is
194232 // what the connection used, not a guess.
195233 organization := cmp .Or (cfg .organization , viper .GetString (confOptions .organization .viperKey ))
196234
197- // What the run produced, and what to do with it. Every step above logs
198- // at debug, so this is what the user is left with.
199235 writeTraceInitSummary (os .Stdout , organization , cfg .project , cfg .workflow , providerNames (installed ))
200236 writeTraceNextSteps (os .Stdout , repoRoot , workDir , installed )
201237
@@ -205,6 +241,7 @@ The organization, project, workflow and require-trace values are saved to
205241
206242 cmd .Flags ().StringVar (& project , "project" , "" , "chainloop project name" )
207243 cmd .Flags ().StringVar (& contract , "contract" , "" , traceContractFlagDesc )
244+ cmd .Flags ().BoolVar (& localFlag , "local" , false , "set up backend-free local mode: no control plane, no login, no workflow; export the evidence to disk with 'chainloop trace export'" )
208245 cmd .Flags ().String ("workflow" , "" , "chainloop workflow name used for trace attestations (defaults to \" ai-coding-session\" )" )
209246 cmd .Flags ().Bool ("require-trace" , false , "block pushes when attestation fails for AI-assisted commits" )
210247 cmd .Flags ().BoolVar (& claudeFlag , "claude" , false , "install Claude Code hooks (default when no harness flag is set)" )
@@ -235,6 +272,44 @@ func writeTraceInitSummary(w io.Writer, organization, project, workflow string,
235272 fmt .Fprintf (w , " harnesses %s\n " , strings .Join (harnesses , ", " ))
236273}
237274
275+ // writeTraceInitLocalSummary reports what a local init set up. It names no
276+ // organization, project or workflow, because local mode has none: it records
277+ // sessions and writes their evidence to disk, with no control plane.
278+ func writeTraceInitLocalSummary (w io.Writer , harnesses []string ) {
279+ fmt .Fprint (w , "\n Congratulations, your repository is initialized in local mode\n \n " )
280+ fmt .Fprintf (w , " mode local (no control plane)\n " )
281+ fmt .Fprintf (w , " harnesses %s\n " , strings .Join (harnesses , ", " ))
282+ }
283+
284+ // writeTraceLocalNextSteps says what is left to do after a local init. Unlike
285+ // the backend path, there is nothing to push: the evidence is read off disk with
286+ // `chainloop trace export`, so that is the step that replaces "commit and push".
287+ func writeTraceLocalNextSteps (w io.Writer , repoRoot , workDir string , installed []trace.Provider ) {
288+ files := make ([]string , 0 , len (installed ))
289+ for _ , p := range installed {
290+ absolute := p .SettingsFile (repoRoot )
291+ if rel , err := filepath .Rel (workDir , absolute ); err == nil {
292+ files = append (files , rel )
293+ continue
294+ }
295+
296+ files = append (files , absolute )
297+ }
298+
299+ fmt .Fprintf (w , `
300+ What's next
301+
302+ 1. Commit the harness configuration. Teammates then only need to run
303+ chainloop trace init (local mode is yours alone and is not committed):
304+ git add %s
305+ 2. Start %s and write some code
306+ 3. Write the session evidence to a directory to inspect it, no push needed:
307+ chainloop trace export ./trace-evidence
308+
309+ Learn more: %s
310+ ` , strings .Join (files , " " ), joinWithOr (providerNames (installed )), traceDocsURL )
311+ }
312+
238313// writeTraceNextSteps says what is left for the user to do. Committing comes
239314// first: what init wrote into the repository is shared, and until it is
240315// committed this setup exists in one working copy only.
@@ -398,7 +473,7 @@ func (f fileSnapshot) restore() error {
398473// trace directory, the managed git hooks and the marker that says init ran. It
399474// is one step so its caller can undo the harness hooks if any part of it fails,
400475// rather than leaving a half-configured repository behind.
401- func writeTraceInitState (cfg * traceInitConfig , repoRoot , gitDir string ) error {
476+ func writeTraceInitState (cfg * traceInitConfig , repoRoot , gitDir string , local bool ) error {
402477 if err := cfg .save (repoRoot ); err != nil {
403478 return err
404479 }
@@ -419,6 +494,19 @@ func writeTraceInitState(cfg *traceInitConfig, repoRoot, gitDir string) error {
419494 return fmt .Errorf ("mark trace initialized: %w" , err )
420495 }
421496
497+ // The local marker lives in the per-clone trace state, not in the shared
498+ // .chainloop.yml, so it never turns attestation off for a teammate who
499+ // clones the repository and runs a normal init. A normal init clears any
500+ // stale marker, so re-running without --local switches the clone back to
501+ // pushing attestations.
502+ if local {
503+ if err := store .MarkLocal (); err != nil {
504+ return fmt .Errorf ("mark local mode: %w" , err )
505+ }
506+ } else if err := store .RemoveLocal (); err != nil {
507+ return fmt .Errorf ("clear local mode: %w" , err )
508+ }
509+
422510 return nil
423511}
424512
0 commit comments